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

## [Unreleased]

## [0.190.0] — 2026-07-14
### Added
- **Callee agents can poke the caller back when they're really done — async delegation, no polling.** A
delegate now closes an agent→agent hand-off by RESUMING the caller's own transcript with the outcome,
so a fire-and-forget delegation wakes the caller instead of making it block on `task_wait`. Opt in with
`task_create({ assignee:"agent:<id>", goal:"…", poke_on_done:true })`: the caller stamps its agent id +
pinned claude transcript on the task (`caller_agent`/`caller_claude_id`), ends its turn, and is
`--resume`d the moment the delegate marks the task **done** (`✅ Really done: …`) or hands it back
**blocked** (`⛔ Handed back: …`). The wake fires immediately via `Automations.pokeCaller` (not the
1-min-floored `schedule()`), is guarded so it never spawns a competing run on a still-live caller, gets
provenance `poke:<task>` (console badge "Poke · …"), and is audited `agent.poked`. The async counterpart
to `wait` (which blocks). `poke_on_done` implies `autoDispatch`.
- **State a GOAL when delegating to an agent — with or without a task in between.** `task_create` /
`task_dispatch` take `goal` as the ergonomic synonym for `criteria` (the single-line objective that runs
a headless delegate under a `/goal` convergence condition until it holds). `ask_agent` gains the same
optional `goal`, so a taskless synchronous consult can hand the delegate an objective it works to before
answering — one vocabulary across both delegation surfaces.
(`src/state/{db,tasks}.ts`, `src/types.ts`, `src/edge/automations.ts`, `src/terminal.ts`,
`src/server.ts`, `src/tenant-registry.ts`, `src/memory/memory-mcp.ts`, `docs/agent-mcp-tools.md`.)

## [0.189.0] — 2026-07-14
### Fixed
- **One-click "Create GitHub App" now actually works — the manifest was invalid.** Every attempt failed on
Expand Down
4 changes: 2 additions & 2 deletions docs/agent-mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ can only ever act as its own session; the namespace/tenant/policy are enforced s
| `kb_history` | `GET /api/kb/history` | `KbStore.history` | R | newest-first revisions |
| `kb_revert` | `POST /api/kb/revert` | `KbStore.revert` | W | itself a new revision; audited `kb.reverted` |
| `ask_human` | `POST /api/ask` + poll | questions | W (blocking) | blocks ~1h polling for the human answer; DMs the addressee out-of-band (`question.notified`) + mirrors to the chat thread so it isn't missed. The human answers **either from the web console Inbox** (`POST /api/questions/:id/answer`, cookie-gated) **or by REPLYING to the Slack/Discord DM** — the notifier binds the question to that DM (`question_dms`), and an inbound reply from that user is matched to the newest pending bound question and recorded as the answer (`answerQuestionFromChat`, attributed to their member email, `canViewQuestion`-gated; audited `question.answered.viaDm`), then acked in-thread. A DM that isn't answering a pending question falls through to the normal chat router. Default addressee = the run's operator (`sessionOwner`); optional `to` (name/email) routes the question to a SPECIFIC other member instead (card + DM target them, and `canViewQuestion` grants them the answer) — the "ask a teammate for info / a confirmation" channel. (Renamed from `ask`; `ask` still accepted as a hidden alias.) |
| `ask_agent` | `POST /api/ask-agent` + poll | `TerminalManager.askAgent`/`agentAskStatus` + `agent_asks` | W (blocking) | **synchronous agent→agent Q&A**, the machine sibling of `ask_human`: spawns the target agent as a one-off HEADLESS delegate (provenance `ask:<caller>`, run-as passthrough, every effect still gated) primed with the question, then long-polls `GET /api/ask-agent/:id` until it answers. Returns the answer inline. NO task row / board / inbox surface — an ephemeral request/response. Self-heals: a delegate that dies without answering (past a grace) → `failed` so the caller unblocks. Same wait envelope as `task_wait` (`AOS_TASK_WAIT_S`, max 6h); audited `agent.asked` |
| `ask_agent` | `POST /api/ask-agent` + poll | `TerminalManager.askAgent`/`agentAskStatus` + `agent_asks` | W (blocking) | **synchronous agent→agent Q&A**, the machine sibling of `ask_human`: spawns the target agent as a one-off HEADLESS delegate (provenance `ask:<caller>`, run-as passthrough, every effect still gated) primed with the question, then long-polls `GET /api/ask-agent/:id` until it answers. Returns the answer inline. Optional single-line `goal` opens the delegate under a `/goal` convergence condition (it works to the objective before answering — the taskless "delegate WITH a goal" path). NO task row / board / inbox surface — an ephemeral request/response. Self-heals: a delegate that dies without answering (past a grace) → `failed` so the caller unblocks. Same wait envelope as `task_wait` (`AOS_TASK_WAIT_S`, max 6h); audited `agent.asked` |
| `answer` | `POST /api/agent/answer` | `TerminalManager.answerAgentAsk` + `agent_asks` | W | delegate-only (exposed when `ASK_ANSWER=1`, keyed on the `ask:` provenance): returns the delegate's result to the agent that asked and ends its run. The server resolves WHICH ask from the delegate's session, so it can't spoof the target; audited `agent.answered` |
| `check_inbox` | `GET /api/inbox` | `TerminalManager.sessionInbox` | R | non-blocking pull of this session's feed |
| `report` | `POST /api/report` | messages | W | `outcome` enum |
Expand All @@ -37,7 +37,7 @@ can only ever act as its own session; the namespace/tenant/policy are enforced s
| `list_capabilities` | `GET /api/agent/policy` | policy preview | R | |
| `policy_check` | `POST /api/agent/policy/check` | policy preview | R | dry-run, no side effects |
| `directory_lookup` | `GET /api/agent/directory` | `TeamStore` | R | people + their chat identities |
| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:<id>`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `criteria` (drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected) |
| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:<id>`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `goal`/`criteria` (synonyms — the objective that drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected). **Async poke-back**: `poke_on_done:true` (agent→agent hand-off only) stamps the caller's agent id + pinned claude transcript on the task (`caller_agent`/`caller_claude_id`); on the delegate closing the loop (done/blocked) the task notifier `--resume`s the caller's transcript with the outcome (`Automations.pokeCaller`, provenance `poke:<task>`, guarded against a still-live caller; audited `agent.poked`) — the fire-and-forget counterpart to `wait` (which blocks). Implies `autoDispatch` |
| `task_list` | `GET /api/tasks/list` | `TaskStore.list` | R | `assignee:"me"` → self; board query/FTS |
| `task_get` | `GET /api/tasks/get` | `TaskStore.withEvents` | R | task + full activity timeline |
| `task_claim` | `POST /api/tasks/claim` | `TaskStore.claim` | W | atomic take (→ doing); loses if already claimed |
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.189.0",
"version": "0.190.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
30 changes: 30 additions & 0 deletions src/edge/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,36 @@ export class Automations {
return this.remove(id);
}

/**
* 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
* (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' };
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 },
});
return { ok: true, sessionId: s.id, tmux: s.tmux };
}

update(id: string, patch: { name?: string; mode?: ExecMode; schedule?: string; filter?: string; task?: string; enabled?: boolean }): Automation | undefined {
const a = this.get(id);
if (!a) return undefined;
Expand Down
21 changes: 16 additions & 5 deletions src/memory/memory-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ const TOOLS = [
properties: {
agent: { type: 'string', description: "The id of the agent to ask (from list_agents), e.g. \"researcher\"." },
question: { type: 'string', description: 'A specific, self-contained question or task — everything the other agent needs to answer without more context.' },
goal: { type: 'string', description: 'Optional single-line objective / definition of done. When set, the other agent works under it as a `/goal` and converges autonomously until it holds before answering — delegate WITH a goal, no task required.' },
timeoutSeconds: { type: 'number', description: 'Optional. How long to block before parking (default ~15min interactive / ~15min unattended, max 6h). On timeout the other agent keeps working; ask again to keep waiting.' },
},
required: ['agent', 'question'],
Expand Down Expand Up @@ -789,7 +790,9 @@ const TOOLS = [
labels: { type: 'array', items: { type: 'string' }, description: 'Optional freeform labels.' },
parentId: { type: 'string', description: 'Parent task id, to file this as a sub-task.' },
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.' },
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.' },
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.' },
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 @@ -1200,7 +1203,7 @@ async function askAgent(args: Record<string, unknown>): Promise<string> {
const res = await fetch(AOS_URL + '/api/ask-agent', {
method: 'POST',
headers: H({ 'content-type': 'application/json' }),
body: JSON.stringify({ session: SESSION, agent, question }),
body: JSON.stringify({ session: SESSION, agent, question, goal: args.goal !== undefined ? String(args.goal) : undefined }),
});
const posted = (await res.json()) as { id?: string; error?: string };
if (posted.error) return `Could not ask ${agent}: ${posted.error}`;
Expand Down Expand Up @@ -1811,10 +1814,13 @@ async function taskCreate(args: Record<string, unknown>): Promise<string> {
labels: Array.isArray(args.labels) ? args.labels.map(String) : undefined,
parentId: args.parentId !== undefined ? String(args.parentId) : undefined,
goalId: args.goalId !== undefined ? String(args.goalId) : undefined,
criteria: args.criteria !== undefined ? String(args.criteria) : undefined,
// `goal` is the ergonomic alias for `criteria` (the /goal convergence condition); either sets it.
criteria: args.criteria !== undefined ? String(args.criteria) : (args.goal !== undefined ? String(args.goal) : undefined),
dependsOn: Array.isArray(args.dependsOn) ? args.dependsOn.map(String) : undefined,
// `wait` implies autoDispatch — you can't block on work that never starts.
autoDispatch: args.autoDispatch === true || args.wait === true,
// `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,
mode: args.mode === 'interactive' ? 'interactive' : undefined,
dueAt: parseDue(args.due),
}),
Expand All @@ -1827,6 +1833,11 @@ async function taskCreate(args: Record<string, unknown>): Promise<string> {
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) {
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
10 changes: 9 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
const target = String(b.agent || '').trim();
const question = String(b.question || '').trim();
if (!question) return sendJson(res, 400, { error: 'question is required' });
const out = tm.askAgent(session, agent, target, question);
const goal = typeof b.goal === 'string' && b.goal.trim() ? b.goal.trim() : undefined;
const out = tm.askAgent(session, agent, target, question, goal);
return sendJson(res, out.error ? 400 : 200, out);
}
const askAgentMatch = p.match(/^\/api\/ask-agent\/([\w-]+)$/);
Expand Down Expand Up @@ -1290,6 +1291,10 @@ 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:');
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 All @@ -1305,6 +1310,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
goalId: typeof b.goalId === 'string' && b.goalId ? b.goalId : undefined,
criteria: typeof b.criteria === 'string' && b.criteria ? b.criteria : undefined,
dependsOn: Array.isArray(b.dependsOn) ? b.dependsOn.map(String) : undefined,
callerAgent: pokeOnDone ? `agent:${agent}` : undefined,
callerClaudeId: pokeOnDone ? tm.sessionClaudeId(session) : undefined,
pokeOnDone: pokeOnDone || undefined,
dueAt: typeof b.dueAt === 'number' && Number.isFinite(b.dueAt) ? b.dueAt : undefined,
createdBy: `agent:${agent}`,
});
Expand Down
9 changes: 9 additions & 0 deletions src/state/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,15 @@ function migrate(db: Db): void {
addColumn(db, 'tasks', 'goal_id', 'TEXT');
addColumn(db, 'tasks', 'criteria', 'TEXT');

// Async poke-back: when one AGENT delegates a task to another (task_create with poke_on_done), we
// stamp the caller's agent id + its pinned claude transcript id here. On the delegate closing the loop
// (task_update → done/blocked), the notifier `--resume`s the caller's transcript with a "really done"
// poke, so a fire-and-forget hand-off wakes the caller instead of the caller having to poll. Nullable;
// a task with no caller (human-filed, or poke off) never pokes. See docs/tasks-plan.md.
addColumn(db, 'tasks', 'caller_agent', 'TEXT');
addColumn(db, 'tasks', 'caller_claude_id', 'TEXT');
addColumn(db, 'tasks', 'poke_on_done', 'INTEGER NOT NULL DEFAULT 0');

// Human verdict on a finished run — the ground-truth signal for the agent maturity score (a person
// who oversaw the run says it did / didn't do what they wanted). One verdict per session, latest wins.
// Feeds src/state/agent-stats.ts as the HIGHEST-confidence outcome layer, above the agent's own
Expand Down
Loading
Loading