Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ new version heading in the same commit.

## [Unreleased]

## [0.265.0] — 2026-07-24
### Changed
- **@mentioning a non-owner agent now asks first (quick answer vs. new session).** Pulling in an agent that
isn't already working the task no longer silently spawns a session. Instead the Discussion shows a choice:
- **Quick answer** — an ephemeral, out-of-band delegate (`ask:<taskId>`, headless, *not* bound to the
task) reads the task + discussion and posts a concise answer via `task_say`, then exits.
- **New session** — starts the governed session on the task (the previous behaviour).
Mentioning the agent that's *already* on the task still just continues its own session.
- **A human's plain reply answers a pending question on the task's live session.** When an agent asked a
question (`ask_human`) and its session is live on the task, a human reply in the Discussion is fed to the
agent as the answer — replies only feed the session when a question is actually open.

## [0.264.4] — 2026-07-24
### Added
- **@mention autocomplete in the task Discussion composer.** Typing `@` opens a suggestion menu
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.264.4",
"version": "0.265.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
35 changes: 33 additions & 2 deletions src/edge/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,17 @@ export class Automations {
for (const tok of mentions) {
if (this.os.agents.has(tok)) {
if (input.agent && tok === input.agent) continue; // don't pull an agent into its own message
const r = this.continueTaskThread(input.taskId, authorLabel, body, tok, input.runAs ?? t.owner ?? undefined);
agentRuns.push({ agent: tok, status: r.status, sessionId: r.sessionId });
// Continue the agent ALREADY on this task (its own live/resumable session); otherwise it's a
// NON-owner agent — don't spawn silently, ask the human (Quick answer vs New session).
const boundAgent = t.lastSessionId ? this.tm.sessionAgent(t.lastSessionId) : undefined;
if (boundAgent === tok) {
const r = this.continueTaskThread(input.taskId, authorLabel, body, tok, input.runAs ?? t.owner ?? undefined);
agentRuns.push({ agent: tok, status: r.status, sessionId: r.sessionId });
} else {
const human = input.author.includes(':') ? (t.owner ?? '') : input.author;
this.tm.postMentionChoice(input.taskId, tok, body, human);
agentRuns.push({ agent: tok, status: 'asked' });
}
continue;
}
const m = this.tm.memberForMention(tok);
Expand All @@ -896,9 +905,31 @@ export class Automations {
mentionedMembers.push(m.id);
}
}
// A human's plain reply answers a pending question on the task's live session (feeds it to the agent).
if (!input.agent && t.lastSessionId && this.tm.isAlive(t.lastSessionId)) {
const qid = this.tm.pendingQuestionFor(t.lastSessionId);
if (qid) this.tm.answerQuestion(qid, body, this.os.team.getMember(input.author)?.email ?? input.author);
}
return { ok: true, entry, mentionedMembers, agentRuns };
}

/**
* A QUICK, out-of-band answer from a non-owner agent (the "Answer" choice on an @mention) — an ephemeral
* headless delegate (provenance `ask:<taskId>`, NOT bound to the task) that reads the task + discussion,
* posts a concise answer via `task_say`, and exits. It does NOT take over the task (no `markDispatched`).
*/
quickAnswer(taskId: string, agentId: string, text: string, runAsMember?: string): { ok: boolean; sessionId?: string; reason?: string } {
const t = this.os.tasks.get(taskId);
if (!t) return { ok: false, reason: 'task not found' };
if (!this.os.agents.has(agentId)) return { ok: false, reason: `unknown agent: ${agentId}` };
const prompt = `You've been asked for a QUICK ANSWER on task ${t.id} ("${t.title}") — you are NOT taking over the task, just answering a question.\n\n` +
`Read it first with task_get({ id: "${t.id}" }) — it carries the full discussion for context. A teammate asked:\n\n${text}\n\n` +
`Post a concise, direct answer with task_say({ id: "${t.id}", message: "…" }), then stop. Do not start doing the work.`;
const s = this.tm.createSession(agentId, `Answer · ${t.title}`, prompt, `ask:${t.id}`, true, undefined, undefined, t.owner, undefined, false);
this.os.audit.append({ ts: Date.now(), runId: s.id, tenant: this.os.tenant, principal: runAsMember ? `member:${runAsMember}` : 'system', type: 'task.mention.answer', data: { taskId, agent: agentId, session: s.id } });
return { ok: true, sessionId: s.id };
}

/**
* `@agent` in a task Discussion → put that agent on the task, reusing the thread-continuity engine
* (the task's `last_session_id` is the binding a Slack/Discord thread table provides). Live session for
Expand Down
21 changes: 21 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3121,6 +3121,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
const taskComment = p.match(/^\/api\/tasks\/([\w-]+)\/comment$/);
const taskMessages = p.match(/^\/api\/tasks\/([\w-]+)\/messages$/);
const taskRead = p.match(/^\/api\/tasks\/([\w-]+)\/read$/);
const taskMention = p.match(/^\/api\/tasks\/mention\/([\w-]+)$/);
const taskDispatch = p.match(/^\/api\/tasks\/([\w-]+)\/dispatch$/);
const taskAttachments = p.match(/^\/api\/tasks\/([\w-]+)\/attachments$/);
const taskAttachmentRaw = p.match(/^\/api\/tasks\/[\w-]+\/attachments\/([\w-]+)\/raw$/);
Expand All @@ -3135,8 +3136,28 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return sendJson(res, 200, {
...found, attachments: os.tasks.attachments(found.task.id), dependents: os.tasks.dependents(found.task.id),
discussion: tm.discussionTimeline(found.task.id), unread: tm.discussionUnread(found.task.id, me),
choices: tm.taskMentionChoices(found.task.id),
});
}
// Resolve a non-owner-agent mention choice: 'answer' (quick out-of-band answer), 'session' (start a
// governed session on the task), or 'dismiss'. Any member (like a task edit).
if (taskMention && method === 'POST') {
const b = await readBody(req);
const action = String(b.action || '');
const c = tm.getMentionChoice(taskMention[1]);
if (!c || c.status !== 'open') return sendJson(res, 404, { error: 'choice not found or already resolved' });
if (action === 'answer') {
autos.quickAnswer(c.taskId, c.agentId, c.message, me.id);
tm.closeMentionChoice(taskMention[1], 'answered');
} else if (action === 'session') {
autos.continueTaskThread(c.taskId, me.name || me.email, c.message, c.agentId, me.id);
tm.closeMentionChoice(taskMention[1], 'answered');
} else if (action === 'dismiss') {
tm.closeMentionChoice(taskMention[1], 'rejected');
} else return sendJson(res, 400, { error: 'unknown action' });
os.audit.append({ ts: Date.now(), runId: `task:${c.taskId}`, tenant: os.tenant, principal: me.email, type: 'task.mention.resolved', data: { agent: c.agentId, action } });
return sendJson(res, 200, { ok: true });
}
// Post a message into a task's Discussion (any member). Parses @mentions → an @member gets an addressed
// Inbox card + DM, an @agent is resumed/spawned on the task. Quiet otherwise (not in the Inbox feed).
if (taskMessages && method === 'POST') {
Expand Down
43 changes: 41 additions & 2 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export interface Session {

export interface FeedMessage {
id: string;
type: 'task' | 'task.chat' | 'update' | 'approval' | 'question' | 'completed' | 'artifact' | 'notification' | 'skill.proposed' | 'goal.proposed' | 'skill.request' | 'secret.request' | 'host.proposed' | 'app.proposed' | 'policy.proposal' | 'automation.proposed' | 'agent.update.proposed';
type: 'task' | 'task.chat' | 'task.mention' | 'update' | 'approval' | 'question' | 'completed' | 'artifact' | 'notification' | 'skill.proposed' | 'goal.proposed' | 'skill.request' | 'secret.request' | 'host.proposed' | 'app.proposed' | 'policy.proposal' | 'automation.proposed' | 'agent.update.proposed';
sessionId: string;
agent: string;
title: string;
Expand Down Expand Up @@ -1281,7 +1281,7 @@ export class TerminalManager {
LEFT JOIN questions q ON m.question_id = q.id
LEFT JOIN term_sessions ts ON m.session_id = ts.id
LEFT JOIN message_state ms ON ms.message_id = m.id AND ms.member_id = ?
WHERE m.dismissed_at IS NULL AND ms.dismissed_at IS NULL AND m.type != 'task.chat'
WHERE m.dismissed_at IS NULL AND ms.dismissed_at IS NULL AND m.type NOT IN ('task.chat', 'task.mention')
ORDER BY m.created_at DESC`,
)
.all<MessageRow>(viewerId);
Expand Down Expand Up @@ -2973,6 +2973,45 @@ export class TerminalManager {
return out;
}

/** Record a pending "how should @agent respond?" choice when a NON-owner agent is @mentioned — the
* human picks Quick answer vs New session (see docs/task-rooms-plan.md). Stored as a `task.mention`
* message (Discussion-only, out of the Inbox feed); returns its id. `human` = the member to address. */
postMentionChoice(taskId: string, agentId: string, text: string, human: string): string {
return this.addMessage({
type: 'task.mention', sessionId: `task:${taskId}`, agent: agentId,
title: `How should @${agentId} respond?`, body: text, status: 'open',
args: { taskId, agentId }, audienceKind: human ? 'member' : 'admins', audienceId: human || undefined,
});
}

/** The open mention-choices on a task (surfaced as a banner in the Discussion). */
taskMentionChoices(taskId: string): { id: string; agentId: string; message: string }[] {
return this.db
.prepare("SELECT id, agent, body FROM messages WHERE session_id = ? AND type = 'task.mention' AND status = 'open' ORDER BY created_at ASC")
.all<{ id: string; agent: string; body: string }>(`task:${taskId}`)
.map((r) => ({ id: r.id, agentId: r.agent, message: r.body }));
}

/** Read one mention-choice (for the resolve route). */
getMentionChoice(msgId: string): { taskId: string; agentId: string; message: string; status: string } | null {
const r = this.db.prepare("SELECT body, status, args FROM messages WHERE id = ? AND type = 'task.mention'").get<{ body: string; status: string; args: string | null }>(msgId);
if (!r) return null;
let a: { taskId?: string; agentId?: string } = {};
try { a = r.args ? JSON.parse(r.args) : {}; } catch { /* ignore */ }
return { taskId: a.taskId ?? '', agentId: a.agentId ?? '', message: r.body, status: r.status };
}

/** Resolve a mention-choice (answered / rejected). */
closeMentionChoice(msgId: string, status: 'answered' | 'rejected'): void {
this.db.prepare("UPDATE messages SET status = ? WHERE id = ? AND type = 'task.mention'").run(status, msgId);
}

/** The newest pending `ask_human` question on a session, if any — so a human's plain Discussion reply
* can answer it (Feature: a reply feeds the live session only when a question is open). */
pendingQuestionFor(sessionId: string): string | undefined {
return this.db.prepare("SELECT id FROM questions WHERE run_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get<{ id: string }>(sessionId)?.id;
}

/** Mark a task's Discussion read for a member (per-member upsert over its `task.chat` rows). */
markDiscussionRead(taskId: string, viewer: Member): void {
for (const r of this.db
Expand Down
26 changes: 21 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7503,7 +7503,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag
const openTask = (id: string) => nav('tasks', id)
const openTaskTab = (id: string, tab: 'discussion' | 'description' | 'session') => nav('tasks', tab === 'discussion' ? id : `${id}/${tab}`)
const closeTask = () => { setEditing(false); nav('tasks') }
const [detail, setDetail] = useState<{ task: Task; events: TaskEvent[]; attachments: TaskAttachment[]; dependents: string[]; discussion: TaskTimelineEntry[]; unread: number } | null>(null)
const [detail, setDetail] = useState<{ task: Task; events: TaskEvent[]; attachments: TaskAttachment[]; dependents: string[]; discussion: TaskTimelineEntry[]; unread: number; choices: { id: string; agentId: string; message: string }[] } | null>(null)
const [busy, setBusy] = useState(false)
const [hint, setHint] = useState('')
// view + filters
Expand Down Expand Up @@ -7613,7 +7613,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag
useEffect(() => {
if (!selId) { setDetail(null); return }
if (editing) return // don't overwrite an in-progress edit on a background refresh
api.task(selId).then((r) => { if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 }) })
api.task(selId).then((r) => { if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] }) })
}, [selId, tasks, editing])
useEffect(() => { setEditing(false); setConfirmDel(false) }, [selId]) // fresh drawer per selection

Expand Down Expand Up @@ -7684,10 +7684,10 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag
await api.patchTask(detail.task.id, { title: eTitle, body: eBody })
setEditing(false); setBusy(false)
await load()
const r = await api.task(detail.task.id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 })
const r = await api.task(detail.task.id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] })
}
// Re-pull the open task's detail (events + attachments + dependency edges) after a mutation that doesn't move columns.
const refreshDetail = async (id: string) => { const r = await api.task(id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 }) }
const refreshDetail = async (id: string) => { const r = await api.task(id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] }) }

if (!tasks) return <div className="text-sm text-muted-foreground">Loading…</div>

Expand Down Expand Up @@ -7949,7 +7949,23 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag
{sessTmux && roomTab_btn('session', <><TerminalSquare className="h-3.5 w-3.5" />Session{live && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-sky-500 motion-safe:animate-pulse" />}</>)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{activeTab === 'discussion' && <div className="h-full px-4 pt-3"><TaskDiscussion pinned taskId={t.id} entries={detail.discussion} unread={detail.unread} me={me} members={members} agents={agents} onChange={() => refreshDetail(t.id)} /></div>}
{activeTab === 'discussion' && (
<div className="flex h-full flex-col px-4 pt-3">
{detail.choices.length > 0 && (
<div className="mb-2 shrink-0 space-y-2">
{detail.choices.map((c) => (
<div key={c.id} className="flex flex-wrap items-center gap-2 rounded-md border border-sky-500/30 bg-sky-500/5 px-3 py-2 text-[13px]">
<span className="min-w-0 flex-1">Pull in <b className="text-sky-600">@{c.agentId}</b> — quick answer, or a full session on this task?</span>
<Button size="xs" disabled={busy} onClick={async () => { setBusy(true); await api.resolveTaskMention(c.id, 'answer'); await refreshDetail(t.id); setBusy(false) }}>Quick answer</Button>
<Button size="xs" variant="outline" disabled={busy} onClick={async () => { setBusy(true); await api.resolveTaskMention(c.id, 'session'); await refreshDetail(t.id); setBusy(false) }}>New session</Button>
<button title="dismiss" disabled={busy} onClick={async () => { setBusy(true); await api.resolveTaskMention(c.id, 'dismiss'); await refreshDetail(t.id); setBusy(false) }} className="text-muted-foreground hover:text-foreground disabled:opacity-50"><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
)}
<div className="min-h-0 flex-1"><TaskDiscussion pinned taskId={t.id} entries={detail.discussion} unread={detail.unread} me={me} members={members} agents={agents} onChange={() => refreshDetail(t.id)} /></div>
</div>
)}
{activeTab === 'description' && (
<div className="h-full overflow-y-auto p-4">
{t.body
Expand Down
3 changes: 2 additions & 1 deletion web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,9 +1399,10 @@ export const api = {
kbDelete: (id: string) => call<{ ok: boolean; error?: string }>('DELETE', `/api/kb/page/${id}`),

tasks: (q = '', status = '') => call<{ tasks: Task[]; counts: Record<TaskStatus, number>; agents: string[]; discussions?: Record<string, TaskDiscussionSummary> }>('GET', `/api/tasks?q=${encodeURIComponent(q)}${status ? `&status=${status}` : ''}`),
task: (id: string) => call<{ task?: Task; events?: TaskEvent[]; attachments?: TaskAttachment[]; dependents?: string[]; discussion?: TaskTimelineEntry[]; unread?: number; error?: string }>('GET', `/api/tasks/${id}`),
task: (id: string) => call<{ task?: Task; events?: TaskEvent[]; attachments?: TaskAttachment[]; dependents?: string[]; discussion?: TaskTimelineEntry[]; unread?: number; choices?: { id: string; agentId: string; message: string }[]; error?: string }>('GET', `/api/tasks/${id}`),
postTaskMessage: (id: string, body: string) => call<{ ok: boolean; entry?: TaskTimelineEntry; mentioned?: string[]; agents?: { agent: string; status: string }[]; error?: string }>('POST', `/api/tasks/${id}/messages`, { body }),
readTaskDiscussion: (id: string) => call<{ ok: boolean }>('POST', `/api/tasks/${id}/read`),
resolveTaskMention: (msgId: string, action: 'answer' | 'session' | 'dismiss') => call<{ ok: boolean; error?: string }>('POST', `/api/tasks/mention/${msgId}`, { action }),
addTask: (b: AddTaskReq) => call<{ ok: boolean; task?: Task; error?: string }>('POST', '/api/tasks', b),
patchTask: (id: string, b: { title?: string; body?: string; status?: TaskStatus; assignee?: string | null; priority?: number; labels?: string[]; mode?: 'headless' | 'interactive'; goalId?: string | null; criteria?: string | null; dependsOn?: string[]; dueAt?: number | null; note?: string }) => call<{ ok: boolean; task?: Task; error?: string }>('PATCH', `/api/tasks/${id}`, b),
commentTask: (id: string, body: string) => call<{ ok: boolean; task?: Task; error?: string }>('POST', `/api/tasks/${id}/comment`, { body }),
Expand Down
Loading