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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ new version heading in the same commit.

## [Unreleased]

## [0.225.0] — 2026-07-16
### Fixed
- **Delegation loop closes itself: the caller is woken when the delegate finishes.** Two gaps meant an
agent that dispatched a task to another agent never learned it was done unless a human told it to "check
that task status": (1) `poke_on_done` was opt-in and defaulted off, and no agent ever set it, so the
caller was never recorded (`caller_agent` was null on every task in prod) and never woken — only the
human owner got a `task.notified` DM; (2) even when a poke did fire, `pokeCaller` *skipped* a caller
whose session was still alive, assuming it "will see the result itself" — but an interactive/resident
caller sits IDLE at the prompt after ending its turn and observes nothing. Now: `task_create` defaults
the async wake **ON** for an agent→agent hand-off (opt out with `poke_on_done:false`; `wait:true` still
supersedes with a synchronous block; self-assignment never wakes), and `pokeCaller` delivers into a live
caller by **injecting** the result into its pane (idle → runs now, mid-turn → queues to the next turn
boundary) instead of skipping — falling back to a `--resume` only when the caller has already exited.

## [0.224.0] — 2026-07-16
### Added
- **"Activity" shortcut in the per-session Operations menu.** The session activity trail side panel
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.224.0",
"version": "0.225.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
47 changes: 35 additions & 12 deletions src/edge/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,31 +415,54 @@ export class Automations {
}

/**
* Wake a CALLER agent by resuming its transcript with a completion poke — the async "really done" signal
* a delegate sends back when it finishes a `poke_on_done` hand-off. Fires IMMEDIATELY (unlike schedule(),
* whose 1-min floor makes it a scheduler, not a wake): `--resume`s `callerClaudeId` with `message` as the
* next turn, so the caller continues its OWN plan with full context. Guarded — if that transcript already
* has a live session, the running caller will observe the outcome itself, so we skip rather than spawn a
* competing run on one conversation (same concern the chat thread-continuity path handles). The delegate
* Wake a CALLER agent with a completion poke — the "really done" signal a delegate sends back when it
* finishes a `poke_on_done` hand-off. Two delivery paths, chosen by whether the caller's transcript
* still has a LIVE session:
*
* - **Live caller → inject** (the common interactive/resident case). The caller ended its turn and is
* sitting IDLE at the prompt — it will NOT observe the delegate's completion on its own (the bug this
* fixes: an interactive session had to be told "check that task status" by hand). So we type the poke
* into its live pane (`injectToSession`, submit): an idle claude runs it immediately, a mid-turn
* claude queues it to the next turn boundary — never a competing `--resume` on one conversation.
* - **Dead caller → resume** (a headless caller that exited at turn-end). Nothing to type into, so we
* `--resume` `callerClaudeId` in a fresh `poke:` run with `message` as the next turn, and the caller
* continues its OWN plan with full context.
*
* Fires IMMEDIATELY (unlike schedule(), whose 1-min floor makes it a scheduler, not a wake). The delegate
* (task assignee) is always the actor, never the caller, so this can't self-wake. Audited `agent.poked`.
*/
pokeCaller(input: { callerAgent: string; callerClaudeId: string; runAs?: string; message: string; source: string }): FireResult {
const agentId = input.callerAgent.startsWith('agent:') ? input.callerAgent.slice('agent:'.length) : input.callerAgent;
if (!this.os.agents.has(agentId)) return { ok: false, reason: `unknown caller agent: ${agentId}` };
if (!input.callerClaudeId) return { ok: false, reason: 'no caller transcript to resume' };
const live = this.db
.prepare("SELECT id FROM term_sessions WHERE claude_session_id = ? AND status = 'running'")
.all<{ id: string }>(input.callerClaudeId)
.some((r) => this.tm.isAlive(r.id));
if (live) return { ok: false, reason: 'caller session still live — it will see the result itself' };
// Prefer delivering into the caller's OWN live session if it still has one bound to this transcript —
// an idle interactive/resident caller would otherwise never learn the delegate finished.
const liveSession = this.db
.prepare("SELECT id, tmux FROM term_sessions WHERE claude_session_id = ? AND status = 'running'")
.all<{ id: string; tmux: string }>(input.callerClaudeId)
.find((r) => this.tm.isAlive(r.id));
if (liveSession) {
const injected = this.tm.injectToSession(liveSession.id, input.message, true, input.runAs ? `member:${input.runAs}` : 'system');
this.os.audit.append({
ts: Date.now(),
runId: liveSession.id,
tenant: this.os.tenant,
principal: input.runAs ? `member:${input.runAs}` : 'system',
type: 'agent.poked',
data: { caller: agentId, source: input.source, runAs: input.runAs ?? null, via: 'inject', ok: injected.ok },
});
// Keystrokes landed → the caller has (or will imminently have) the result. On the rare inject failure
// (unreadable pane) fall through to a resume so the poke is never silently dropped.
if (injected.ok) return { ok: true, sessionId: liveSession.id, tmux: liveSession.tmux };
}
const s = this.tm.createSession(agentId, `Poke ← ${input.source}`, input.message, `poke:${input.source}`, true, undefined, undefined, input.runAs, input.callerClaudeId);
this.os.audit.append({
ts: Date.now(),
runId: s.id,
tenant: this.os.tenant,
principal: input.runAs ? `member:${input.runAs}` : 'system',
type: 'agent.poked',
data: { caller: agentId, source: input.source, runAs: input.runAs ?? null },
data: { caller: agentId, source: input.source, runAs: input.runAs ?? null, via: 'resume' },
});
return { ok: true, sessionId: s.id, tmux: s.tmux };
}
Expand Down
23 changes: 16 additions & 7 deletions src/memory/memory-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ const TOOLS = [
goalId: { type: 'string', description: 'Link this task to a strategic goal it advances (see goal_list for ids). Its progress then counts toward that goal.' },
goal: { type: 'string', description: 'The single-line objective the delegate must achieve — the definition of done. On a headless auto-dispatched task the worker runs under this as a `/goal` and converges autonomously until it holds (alias for `criteria`). This is what to state when you delegate WITH a goal.' },
criteria: { type: 'string', description: 'A single-line, transcript-verifiable acceptance condition, e.g. "all tests in test/auth pass". When set on a headless auto-dispatched task, the worker runs under this as a `/goal` and converges autonomously until it holds. Synonym of `goal`.' },
poke_on_done: { type: 'boolean', description: 'Fire-and-forget delegation with an async wake-up: hand off, end your turn, and be RESUMED automatically when the delegate finishes (or blocks) — no polling. The async counterpart to `wait` (which blocks). Only for an agent-assigned auto-dispatched task. Default false.' },
poke_on_done: { type: 'boolean', description: 'Async wake-up on completion: hand off, end your turn, and be woken automatically when the delegate finishes (or blocks) — no polling. The async counterpart to `wait` (which blocks in-line). DEFAULTS ON when you delegate to another agent, so the loop closes itself — set it to false only when you truly want fire-and-forget and do NOT need the result. Ignored on a self-assignment or an open/human task (no separate caller to wake).' },
dependsOn: { type: 'array', items: { type: 'string' }, description: 'Task ids this task is BLOCKED BY — it will not dispatch until they are all done. To encode a pipeline: file the earlier steps first, capture their ids from the results, and pass them here so this step waits for them.' },
autoDispatch: { type: 'boolean', description: 'If true and assigned to an agent, the board auto-spawns a session to work it. Default false.' },
mode: { type: 'string', enum: ['headless', 'interactive'], description: 'How a dispatched session runs: "headless" (default — works to completion then exits) or "interactive" (an attachable TUI a human drives).' },
Expand Down Expand Up @@ -2101,6 +2101,15 @@ function parseDue(due: unknown): number | null | undefined {
async function taskCreate(args: Record<string, unknown>): Promise<string> {
const title = String(args.title ?? '').trim();
if (!title) return 'A task needs a title.';
// Default the delegation loop CLOSED: an agent→agent hand-off wakes the caller when the delegate
// finishes, so a dispatch doesn't silently strand the caller waiting for a result it never learns
// arrived. `wait:true` supersedes (it delivers the result synchronously in-line); opt out of the wake
// with `poke_on_done:false`. Self-assignment ("me") never wakes — the server drops a self-poke too.
const assigneeStr = args.assignee !== undefined ? String(args.assignee) : '';
const toAgent = assigneeStr === 'me' || assigneeStr.startsWith('agent:');
const waiting = args.wait === true;
const pokeOnDone = args.poke_on_done === true
|| (toAgent && assigneeStr !== 'me' && !waiting && args.poke_on_done !== false);
const res = await fetch(AOS_URL + '/api/tasks/create', {
method: 'POST',
headers: H({ 'content-type': 'application/json' }),
Expand All @@ -2117,8 +2126,8 @@ async function taskCreate(args: Record<string, unknown>): Promise<string> {
dependsOn: Array.isArray(args.dependsOn) ? args.dependsOn.map(String) : undefined,
// `wait` (block) and `poke_on_done` (async wake) both imply autoDispatch — you can't await/be-woken
// by work that never starts.
autoDispatch: args.autoDispatch === true || args.wait === true || args.poke_on_done === true,
pokeOnDone: args.poke_on_done === true,
autoDispatch: args.autoDispatch === true || waiting || pokeOnDone,
pokeOnDone,
mode: args.mode === 'interactive' ? 'interactive' : undefined,
dueAt: parseDue(args.due),
}),
Expand All @@ -2127,13 +2136,13 @@ async function taskCreate(args: Record<string, unknown>): Promise<string> {
if (!d.ok) return `Could not create the task: ${d.error ?? 'unknown error'}`;
const who = args.assignee ? ` (assigned to ${String(args.assignee)})` : ' (open — anyone can claim it)';
// wait:true → delegate synchronously: file it, then block until the delegate closes the loop.
if (args.wait === true) {
if (waiting) {
const outcome = await taskWait({ id: d.id, timeoutSeconds: args.timeoutSeconds });
return `Filed task ${d.id}: "${title}"${who}.\n${outcome}`;
}
// poke_on_done → fire-and-forget async: don't poll or wait. End your turn; you'll be resumed with the
// result the moment the delegate finishes (or blocks).
if (args.poke_on_done === true) {
// poke_on_done (explicit or the agent→agent default) → don't poll or wait. End your turn; you'll be
// woken with the result the moment the delegate finishes (or blocks).
if (pokeOnDone) {
return `Filed task ${d.id}: "${title}"${who}. You'll be woken with the result when it finishes — you can end your turn now; no need to poll.`;
}
return `Filed task ${d.id}: "${title}"${who}. Track it with task_get "${d.id}".`;
Expand Down
11 changes: 7 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,10 +1420,13 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return sendJson(res, 200, { ok: false, error: `no agent "${targetId}" — assign to one of: ${valid} (call list_agents to see the roster)` });
}
}
// Poke-back: an agent delegating to ANOTHER agent can ask to be woken when the delegate finishes.
// We stamp the caller's agent id + pinned claude transcript so the task notifier can `--resume` this
// session on done/blocked. Only meaningful for an agent→agent hand-off (a poke has a caller to wake).
const pokeOnDone = (b.pokeOnDone === true || b.pokeOnDone === 'true') && !!assignee && assignee.startsWith('agent:');
// Poke-back: an agent delegating to ANOTHER agent is woken when the delegate finishes (the MCP layer
// defaults this ON for agent→agent hand-offs so the delegation loop closes itself). We stamp the
// caller's agent id + pinned claude transcript so the task notifier can wake this session on
// done/blocked. Only for a hand-off to a DIFFERENT agent — a self-assignment has no separate caller to
// wake (and waking your own transcript from its own delegate would loop), so we drop the self-poke.
const pokeOnDone = (b.pokeOnDone === true || b.pokeOnDone === 'true')
&& !!assignee && assignee.startsWith('agent:') && assignee !== `agent:${agent}`;
try {
// owner defaults to the creating session's run-as member — HUMAN PASSTHROUGH: a task filed by an
// agent acting as Alice dispatches (later) as Alice too, so accountability ladders to the person.
Expand Down
Loading