@openparachute/agent 0.2.0 → 0.2.3-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +8 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/registry.ts +65 -27
  6. package/src/daemon.ts +311 -12
  7. package/src/def-vault-triggers.ts +317 -0
  8. package/src/preflight.ts +139 -0
  9. package/src/spawn-agent.ts +16 -0
  10. package/src/step-up.ts +316 -0
  11. package/src/terminal-ui.ts +73 -0
  12. package/src/transports/http-ui.ts +10 -8
  13. package/src/transports/vault.ts +40 -22
  14. package/src/ui-kit.ts +6 -3
  15. package/src/ui-ticket.ts +121 -0
  16. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  17. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  18. package/web/ui/dist/index.html +15 -0
  19. package/src/_parked/interactive-spawn.test.ts +0 -324
  20. package/src/_parked/interactive-spawn.ts +0 -701
  21. package/src/agent-defs.test.ts +0 -1504
  22. package/src/agent-mcp-config.test.ts +0 -115
  23. package/src/agents.test.ts +0 -360
  24. package/src/auth.test.ts +0 -46
  25. package/src/backends/attached-queue.test.ts +0 -376
  26. package/src/backends/programmatic.test.ts +0 -1715
  27. package/src/backends/registry.test.ts +0 -1494
  28. package/src/backends/stream-json.test.ts +0 -570
  29. package/src/channel-backend-wiring.test.ts +0 -237
  30. package/src/credentials.test.ts +0 -274
  31. package/src/cron.test.ts +0 -342
  32. package/src/daemon-agent-def-api.test.ts +0 -166
  33. package/src/daemon-agent-defs-api.test.ts +0 -953
  34. package/src/daemon-agent-env-api.test.ts +0 -338
  35. package/src/daemon-attached-queue-store.test.ts +0 -65
  36. package/src/daemon-config-api.test.ts +0 -962
  37. package/src/daemon-jobs-api.test.ts +0 -271
  38. package/src/daemon-vault-chat.test.ts +0 -250
  39. package/src/daemon.test.ts +0 -746
  40. package/src/def-vaults.test.ts +0 -136
  41. package/src/delivery-state.test.ts +0 -110
  42. package/src/effective-env.test.ts +0 -114
  43. package/src/grants.test.ts +0 -638
  44. package/src/hub-jwt.test.ts +0 -161
  45. package/src/jobs.test.ts +0 -245
  46. package/src/mcp-http.test.ts +0 -265
  47. package/src/mint-token.test.ts +0 -152
  48. package/src/module-manifest.test.ts +0 -158
  49. package/src/programmatic-wiring.test.ts +0 -838
  50. package/src/registry.test.ts +0 -227
  51. package/src/resolve-port.test.ts +0 -64
  52. package/src/routing.test.ts +0 -184
  53. package/src/runner.test.ts +0 -506
  54. package/src/sandbox/config.test.ts +0 -150
  55. package/src/sandbox/egress.test.ts +0 -113
  56. package/src/sandbox/live-seatbelt.test.ts +0 -277
  57. package/src/sandbox/mounts.test.ts +0 -154
  58. package/src/sandbox/sandbox.test.ts +0 -168
  59. package/src/services-manifest.test.ts +0 -106
  60. package/src/spa-serve.test.ts +0 -116
  61. package/src/spawn-agent-cli.test.ts +0 -172
  62. package/src/spawn-agent.test.ts +0 -1218
  63. package/src/spawn-deps.test.ts +0 -54
  64. package/src/terminal-assets.test.ts +0 -50
  65. package/src/terminal.test.ts +0 -530
  66. package/src/transports/http-ui.test.ts +0 -455
  67. package/src/transports/telegram.test.ts +0 -174
  68. package/src/transports/vault.test.ts +0 -2011
  69. package/src/ui-kit.test.ts +0 -178
  70. package/web/ui/tsconfig.json +0 -21
@@ -52,7 +52,7 @@
52
52
  "module": "vault",
53
53
  "event": "note.created",
54
54
  "filter": {
55
- "tags": ["#agent/message/inbound"],
55
+ "tags": ["agent/message/inbound"],
56
56
  "has_metadata": ["channel"],
57
57
  "missing_metadata": ["channel_inbound_rendered_at"]
58
58
  }
@@ -85,7 +85,7 @@
85
85
  "module": "vault",
86
86
  "event": "note.created",
87
87
  "filter": {
88
- "tags": ["#agent/definition"]
88
+ "tags": ["agent/definition"]
89
89
  }
90
90
  },
91
91
  "sink": {
@@ -110,7 +110,7 @@
110
110
  "module": "vault",
111
111
  "event": "note.updated",
112
112
  "filter": {
113
- "tags": ["#agent/definition"]
113
+ "tags": ["agent/definition"]
114
114
  }
115
115
  },
116
116
  "sink": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.0",
3
+ "version": "0.2.3-rc.10",
4
4
  "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -24,6 +24,8 @@
24
24
  "test:spa": "cd web/ui && bun run test",
25
25
  "test:all": "bun run test && bun run test:spa",
26
26
  "test:e2e": "bun e2e/llm/run.ts",
27
+ "lint": "biome check .",
28
+ "lint:fix": "biome check --write .",
27
29
  "typecheck": "tsc --noEmit",
28
30
  "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
29
31
  "prepack": "bun run build:spa"
@@ -39,6 +41,11 @@
39
41
  "typescript": "^5"
40
42
  },
41
43
  "devDependencies": {
44
+ "@biomejs/biome": "^1.9.4",
42
45
  "@types/bun": "latest"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/ParachuteComputer/parachute-agent.git"
43
50
  }
44
51
  }
package/src/agent-defs.ts CHANGED
@@ -389,6 +389,15 @@ export function parseAgentDef(note: {
389
389
  }
390
390
 
391
391
  // Filesystem read scope.
392
+ //
393
+ // NOTE (step-up, agent#80): `filesystem: "full"` is the dangerous, full-disk
394
+ // case. The step-up PIN gate is enforced on the HTTP spawn path only
395
+ // (`POST /api/agents` in daemon.ts). This VAULT-NATIVE path (a #agent/definition
396
+ // note with `filesystem: full`) is NOT step-up-gated — registering it requires
397
+ // `vault:write` to author the note, which is itself separately scope-gated, so a
398
+ // step-up challenge here would gate a capability the caller already had to hold a
399
+ // write credential to reach. If the threat model is ever revisited (e.g. less-
400
+ // trusted note authors), this is the gap to close.
392
401
  const filesystem = metaStr(meta.filesystem);
393
402
  if (filesystem !== undefined) {
394
403
  if (filesystem !== "workspace" && filesystem !== "full") {
package/src/auth.ts CHANGED
@@ -7,13 +7,26 @@
7
7
  * - Layer 1 (bridge / session↔channel): the bridge presents the token as an
8
8
  * `Authorization: Bearer` header on `/events` + `/api/*`.
9
9
  * - Layer 2 (human / chat UI): the page fetches a short-lived token from the
10
- * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
- * `send` POST, and as a `?token=` query param on the `/ui/events` SSE
12
- * (EventSource can't set headers).
10
+ * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
+ * `send` POST. For the browser SSE streams (`/ui/events`,
12
+ * `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
13
+ * header on — the page does NOT put the JWT in the URL. Instead it mints a
14
+ * one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
15
+ * opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
13
16
  *
14
- * `requireScope` accepts the token from EITHER source so one helper guards both
15
- * layers. The no-token path short-circuits before any JWKS fetch, keeping it
16
- * unit-testable without a live hub (same approach Layer 1 used).
17
+ * `requireScope` accepts the token from a Bearer header (and, for the
18
+ * agent:admin terminal WebSocket only, a `?token=` query param). The no-token
19
+ * path short-circuits before any JWKS fetch, keeping it unit-testable without a
20
+ * live hub (same approach Layer 1 used).
21
+ *
22
+ * WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
23
+ * access/proxy log, browser history, or network trace — a credential leak
24
+ * mitigated before only by the token's short TTL. The browser SSE endpoints now
25
+ * trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
26
+ * JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
27
+ * path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
28
+ * (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
29
+ * out of this change's scope.
17
30
  *
18
31
  * DUAL-ACCEPT (channel→agent rename transition,
19
32
  * `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
@@ -26,6 +39,8 @@
26
39
 
27
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
28
41
  import { extractBearer } from "@openparachute/scope-guard";
42
+ import { consumeTicket } from "./ui-ticket.ts";
43
+ import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
29
44
 
30
45
  /** Agent scopes, declared here so callers share one spelling. */
31
46
  export const SCOPE_READ = "agent:read" as const;
@@ -79,15 +94,18 @@ export function json(data: unknown, status = 200): Response {
79
94
 
80
95
  /**
81
96
  * Extract a presented token from a request: the `Authorization: Bearer` header
82
- * first (the bridge + the UI's POST), falling back to a `?token=` query param
83
- * (the SSE case `EventSource` can't set headers). Returns null if neither is
84
- * present.
97
+ * first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
98
+ * `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
99
+ * opts into the query param is the agent:admin terminal WebSocket
100
+ * (`new WebSocket()` can't set headers); the browser SSE streams moved to the
101
+ * one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
102
+ * null if neither source is present.
85
103
  */
86
104
  export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
87
105
  const bearer = extractBearer(req.headers.get("authorization"));
88
106
  if (bearer) return bearer;
89
- // `?token=` is opt-in (the SSE case only). The bridge + the UI POST present a
90
- // Bearer header, so they never enable it — keeps query-param tokens off every
107
+ // `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
108
+ // a Bearer header, so they leave it false keeping query-param JWTs off every
91
109
  // endpoint that doesn't strictly need them (and out of those access logs).
92
110
  if (allowQueryParam) {
93
111
  const q = url.searchParams.get("token");
@@ -99,9 +117,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
99
117
  /**
100
118
  * Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
101
119
  * as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
102
- * accept a `?token=` query param (the SSE case only — `EventSource` can't set
103
- * headers). Bridge + UI-POST callers leave it false, so query-param tokens are
104
- * confined to the one endpoint that needs them.
120
+ * accept a `?token=` query param (the agent:admin terminal WebSocket only —
121
+ * `new WebSocket()` can't set headers). All other callers leave it false, so
122
+ * query-param JWTs are confined to that one endpoint. Browser SSE streams use
123
+ * `requireSseTicket` (the one-time ticket), not this.
105
124
  *
106
125
  * Returns `null` when the request is authorized (caller proceeds), or a
107
126
  * `Response` (401/403) the caller must return as-is.
@@ -138,3 +157,152 @@ export async function requireScope(
138
157
  );
139
158
  }
140
159
  }
160
+
161
+ /**
162
+ * Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
163
+ * Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
164
+ * 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
165
+ * opaque ticket carrying ONLY the token's validated scopes. The ticket — never
166
+ * the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
167
+ * expires_at }`, or the gate's 401/403) for the caller to return as-is.
168
+ *
169
+ * `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
170
+ * tests can assert what scopes get carried without reaching into the singleton.
171
+ * Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
172
+ * and short-circuits before any ticket is created — minting without a valid
173
+ * bearer would be an auth bypass.
174
+ */
175
+ export async function mintSseTicket(
176
+ req: Request,
177
+ url: URL,
178
+ scope: string,
179
+ mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
180
+ ): Promise<Response> {
181
+ const token = extractToken(req, url); // Bearer header ONLY — never a query param.
182
+ if (!token) {
183
+ return json({ error: "unauthorized", message: "Bearer token required" }, 401);
184
+ }
185
+ let scopes: string[];
186
+ try {
187
+ const claims = await validateHubJwt(token);
188
+ if (!grantsScope(claims.scopes, scope)) {
189
+ return json(
190
+ { error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
191
+ 403,
192
+ );
193
+ }
194
+ // Carry the token's OWN validated scopes — never widen beyond what it holds.
195
+ scopes = claims.scopes;
196
+ } catch (err) {
197
+ return json(
198
+ { error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
199
+ 401,
200
+ );
201
+ }
202
+ const { ticket, expiresAt } = mint(scopes);
203
+ return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
204
+ }
205
+
206
+ /**
207
+ * Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
208
+ * EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
209
+ * the ticket (single-use: a second connect 401s), then assert the ticket's carried
210
+ * scopes include `scope` (the ticket can never authorize more than the JWT that
211
+ * minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
212
+ * when authorized (caller opens the stream) or a 401 `Response` to return as-is.
213
+ *
214
+ * No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
215
+ * captured in the ticket; consume is a pure in-memory lookup. So an absent /
216
+ * expired / already-used / under-scoped ticket all map to 401 with no network I/O.
217
+ */
218
+ export function requireSseTicket(url: URL, scope: string): Response | null {
219
+ const consumed = consumeTicket(url.searchParams.get("ticket"));
220
+ if (!consumed) {
221
+ return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
222
+ }
223
+ if (!grantsScope(consumed.scopes, scope)) {
224
+ return json(
225
+ { error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
226
+ 403,
227
+ );
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * The header a request carries the step-up token on (agent#80). The terminal
234
+ * WebSocket — which `new WebSocket()` can't set a header on — uses the
235
+ * `?step_up=` query param instead (mirroring the `?token=` exception).
236
+ */
237
+ export const STEP_UP_TOKEN_HEADER = "x-step-up-token";
238
+
239
+ /** Extract a presented step-up token: the header first, then `?step_up=` when allowed. */
240
+ export function extractStepUpToken(req: Request, url: URL, allowQueryParam = false): string | null {
241
+ const header = req.headers.get(STEP_UP_TOKEN_HEADER);
242
+ if (header && header.length > 0) return header;
243
+ if (allowQueryParam) {
244
+ const q = url.searchParams.get("step_up");
245
+ if (q && q.length > 0) return q;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * SECOND-FACTOR gate (agent#80) for the genuinely dangerous `agent:admin` actions:
252
+ * set/rotate credentials, open a terminal, spawn a `filesystem: full` agent. The
253
+ * caller runs {@link requireScope}(`agent:admin`) FIRST; this asserts — IN ADDITION —
254
+ * a valid step-up token (the operator entered their PIN recently).
255
+ *
256
+ * - Step-up NOT configured (no PIN set) → returns `{ ok: false, reason: "setup" }`.
257
+ * The caller maps it to `403 { error: "step_up_required", reason: "setup" }` so
258
+ * the UI runs its FIRST-TIME PIN-setup flow before the action.
259
+ * - PIN configured + valid token → `{ ok: true }` (the action proceeds).
260
+ * - PIN configured + missing/expired token → `{ ok: false, reason: "token" }` →
261
+ * `403 { error: "step_up_required" }` so the UI PROMPTS for the PIN.
262
+ *
263
+ * The 403 is deliberately DISTINCT from `requireScope`'s 401 (no/invalid Bearer):
264
+ * a 401 means "re-authenticate", a 403 `step_up_required` means "enter your PIN".
265
+ * The step-up token NEVER widens scope — the request already passed `agent:admin`;
266
+ * this is purely a recency re-confirm on top.
267
+ *
268
+ * `allowQueryParam: true` accepts `?step_up=` for the terminal WebSocket only.
269
+ * Pure in-memory token check — no I/O on the gated request path, no secret logged.
270
+ */
271
+ export function requireStepUp(
272
+ req: Request,
273
+ url: URL,
274
+ allowQueryParam = false,
275
+ opts?: { configured?: () => boolean; valid?: (token: string | null) => boolean },
276
+ ): { ok: true } | { ok: false; response: Response } {
277
+ const isConfigured = opts?.configured ?? (() => isStepUpConfigured());
278
+ const isValid = opts?.valid ?? ((t: string | null) => isStepUpTokenValid(t));
279
+ if (!isConfigured()) {
280
+ // No PIN yet — the UI must set one before this action can proceed.
281
+ return {
282
+ ok: false,
283
+ response: json(
284
+ {
285
+ error: "step_up_required",
286
+ reason: "setup",
287
+ message: "set a step-up PIN before performing this action",
288
+ },
289
+ 403,
290
+ ),
291
+ };
292
+ }
293
+ const token = extractStepUpToken(req, url, allowQueryParam);
294
+ if (!isValid(token)) {
295
+ return {
296
+ ok: false,
297
+ response: json(
298
+ {
299
+ error: "step_up_required",
300
+ reason: "token",
301
+ message: "enter your step-up PIN to confirm this action",
302
+ },
303
+ 403,
304
+ ),
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
@@ -174,8 +174,16 @@ export interface ThreadNote {
174
174
  * per channel, multi-threaded writes one per fire. A write failure is the implementation's
175
175
  * to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
176
176
  * the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
177
+ *
178
+ * RETURNS the WRITTEN thread-note's id (`{ id }`) so the drain can use it as a RESOLVABLE
179
+ * `source_thread` on the agent-to-agent callback (agent#124) — for BOTH modes, this is the
180
+ * actual note an orchestrator can pull with `query-notes { id }` (single-threaded: the
181
+ * deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
182
+ * `Threads/<safeChannel>/<uuid>` note). `void` is in the union (back-compat) — a transport
183
+ * with no durable store, or one that can't surface an id, returns it and the drain falls
184
+ * back to the per-turn id.
177
185
  */
178
- export type WriteThread = (thread: ThreadNote) => Promise<void>;
186
+ export type WriteThread = (thread: ThreadNote) => Promise<{ id?: string } | void>;
179
187
 
180
188
  /**
181
189
  * A callback delivered back to a SENDER's channel when a turn it requested finishes —
@@ -216,15 +224,16 @@ export interface CallbackMeta {
216
224
  /** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
217
225
  source_channel: string;
218
226
  /**
219
- * The per-turn thread id the drain minted. RESOLVABILITY DIFFERS BY MODE:
220
- * - multi-threaded: this IS the per-fire note's leaf the orchestrator can pull the
221
- * thread note at `Threads/<channel>/<source_thread>`.
222
- * - single-threaded: this is a per-turn CORRELATION id, NOT the note leaf (the
223
- * single-threaded note lives at the deterministic `Threads/<channel>/<name>`), so it
224
- * is NOT directly resolvable. Use `source_message` as the reliable pull-link for a
225
- * single-threaded recipient. Making this a resolvable thread id for both modes
226
- * (widen the writeThread seam to return the written note id) is tracked as a
227
- * follow-up (parachute-agent#124).
227
+ * The WRITTEN thread-note id RESOLVABLE for BOTH modes (agent#124): an orchestrator can
228
+ * always pull the recipient's full thread record with `query-notes { id: source_thread }`,
229
+ * even on an error/empty/tool-only turn (the thread note is written BEFORE the outbound
230
+ * reply, so its id exists when there's no `source_message`).
231
+ * - multi-threaded: the per-fire note id (`Threads/<safeChannel>/<uuid>`).
232
+ * - single-threaded: the deterministic note id (`Threads/<safeChannel>/<safeName>`) NOT
233
+ * the per-turn correlation id (the pre-#124 bug: that correlation id wasn't the note leaf
234
+ * for single-threaded, so it couldn't be resolved).
235
+ * The drain sources this from {@link WriteThread}'s returned id; if the seam can't surface
236
+ * one (no durable store) it falls back to the per-turn id (still a stable provenance token).
228
237
  */
229
238
  source_thread: string;
230
239
  /**
@@ -835,7 +844,7 @@ export class ProgrammaticAgentRegistry {
835
844
  // thread note captures the turn outcome, so a failed turn is still a queryable
836
845
  // `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
837
846
  // a per-fire note).
838
- await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
847
+ const threadNoteId = await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
839
848
  threadId: turnThreadId,
840
849
  phase: "end",
841
850
  // No `result` (the backend threw) → NO session to persist. We never write a
@@ -848,8 +857,10 @@ export class ProgrammaticAgentRegistry {
848
857
  // no-reply) — best-effort.
849
858
  await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
850
859
  // CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
851
- // hang waiting forever. No outbound note was produced, so no `source_message`.
852
- await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
860
+ // hang waiting forever. No outbound note was produced, so no `source_message`; the
861
+ // RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
862
+ // still pull the recipient's thread on a no-reply turn (agent#124).
863
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
853
864
  continue;
854
865
  }
855
866
 
@@ -863,7 +874,7 @@ export class ProgrammaticAgentRegistry {
863
874
  // BOTH modes record the failed turn (status:error) on the thread note so a failure
864
875
  // always leaves a queryable trace (single-threaded upserts the rolling thread,
865
876
  // marking it errored; multi-threaded writes a per-fire status:error note).
866
- await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
877
+ const threadNoteId = await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
867
878
  threadId: turnThreadId,
868
879
  phase: "end",
869
880
  // Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
@@ -878,8 +889,9 @@ export class ProgrammaticAgentRegistry {
878
889
  // no-reply) — best-effort.
879
890
  await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
880
891
  // CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
881
- // sub-task failed and can react. No delivered reply, so no `source_message`.
882
- await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
892
+ // sub-task failed and can react. No delivered reply, so no `source_message`; the
893
+ // RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
894
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
883
895
  continue;
884
896
  }
885
897
 
@@ -891,7 +903,11 @@ export class ProgrammaticAgentRegistry {
891
903
  // multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
892
904
  // logged + the turn still resolves (we never re-run a `claude -p` turn — that would
893
905
  // burn quota for a duplicate).
894
- await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
906
+ // Capture the WRITTEN thread-note id the RESOLVABLE `source_thread` for the callback
907
+ // (agent#124). The same note id is reused for the outbound-failure re-record below
908
+ // (sameTurn → same note), so a callback on either terminal path points at a pullable
909
+ // thread record.
910
+ let threadNoteId = await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
895
911
  threadId: turnThreadId,
896
912
  phase: "end",
897
913
  // Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
@@ -938,7 +954,9 @@ export class ProgrammaticAgentRegistry {
938
954
  // `sameTurn` so this updates the note the `ok` record above just wrote (one
939
955
  // note, no turn_count double-count) rather than minting a duplicate / advancing
940
956
  // the count (the FIX-1 re-record bug the reviewer caught).
941
- await this.recordThread(
957
+ // Re-record returns the SAME note's id (sameTurn upsert / same per-fire note) — use
958
+ // it as the callback `source_thread` (agent#124), falling back to the ok-record id.
959
+ threadNoteId = (await this.recordThread(
942
960
  handle,
943
961
  msg,
944
962
  "error",
@@ -955,7 +973,7 @@ export class ProgrammaticAgentRegistry {
955
973
  // write failed. Only claude's echoed id (FIX 2), never the passed uuid.
956
974
  ...(result.sessionId ? { session: result.sessionId } : {}),
957
975
  },
958
- );
976
+ )) ?? threadNoteId;
959
977
  this.emitTurnEvent(channel, {
960
978
  kind: "error",
961
979
  error: `reply produced but not saved: ${delivered.error}`,
@@ -963,8 +981,8 @@ export class ProgrammaticAgentRegistry {
963
981
  // CALLBACK as status:error — the reply was produced but NOT delivered, so the
964
982
  // turn did not truly succeed; the orchestrator must learn that. No `source_message`
965
983
  // (the outbound note never landed); the undelivered text lives in the error thread
966
- // note for an operator to recover.
967
- await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
984
+ // note for an operator to recover — pull it via the RESOLVABLE `source_thread`.
985
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
968
986
  continue;
969
987
  }
970
988
  }
@@ -976,8 +994,10 @@ export class ProgrammaticAgentRegistry {
976
994
  // (empty/tool-only turn → clean resolve, no note expected).
977
995
  this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
978
996
  // CALLBACK on success — the turn finished cleanly (status:ok). `sourceMessage` is the
979
- // delivered reply note (when there was one) the orchestrator pulls the full result from.
980
- await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage);
997
+ // delivered reply note (when there was one) the orchestrator pulls the full result from;
998
+ // `source_thread` (the WRITTEN thread-note id, agent#124) is the RESOLVABLE pull-link in
999
+ // both modes, including an empty/tool-only turn where there's no `sourceMessage`.
1000
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage, threadNoteId);
981
1001
  }
982
1002
  }
983
1003
 
@@ -1013,6 +1033,7 @@ export class ProgrammaticAgentRegistry {
1013
1033
  turnThreadId: string,
1014
1034
  status: "ok" | "error",
1015
1035
  sourceMessage?: string,
1036
+ sourceThreadId?: string,
1016
1037
  ): Promise<void> {
1017
1038
  // Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
1018
1039
  if (!this.writeCallback) return;
@@ -1041,11 +1062,17 @@ export class ProgrammaticAgentRegistry {
1041
1062
  // source_message are echoed/included only when present. The daemon's WriteCallback
1042
1063
  // wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
1043
1064
  // — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
1065
+ //
1066
+ // `source_thread` is the WRITTEN thread-note id (agent#124) — RESOLVABLE for BOTH modes
1067
+ // (`query-notes { id: source_thread }`), available even on an error/empty/tool-only turn
1068
+ // (the thread note is written before the outbound). Fall back to the per-turn id only when
1069
+ // the thread seam surfaced none (no durable store / a write failure) — still a stable
1070
+ // provenance token, just not a pullable note.
1044
1071
  const meta: CallbackMeta = {
1045
1072
  callback: "true",
1046
1073
  status,
1047
1074
  source_channel: handle.channel,
1048
- source_thread: turnThreadId,
1075
+ source_thread: sourceThreadId ?? turnThreadId,
1049
1076
  ...(sourceMessage ? { source_message: sourceMessage } : {}),
1050
1077
  ...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
1051
1078
  delegation_depth: String(incomingDepth + 1),
@@ -1071,6 +1098,11 @@ export class ProgrammaticAgentRegistry {
1071
1098
  * agent name (single-threaded's thread is "named after the definition"). Best-effort: a
1072
1099
  * write failure is LOGGED, never thrown out — a missing thread note must not strand the
1073
1100
  * queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
1101
+ *
1102
+ * RETURNS the WRITTEN thread-note id so the drain can use it as a RESOLVABLE
1103
+ * `source_thread` on the agent-to-agent callback (agent#124), for BOTH modes. `undefined`
1104
+ * when no sink is wired, the write failed, or the seam surfaced no id — the drain then
1105
+ * falls back to the per-turn id.
1074
1106
  */
1075
1107
  private async recordThread(
1076
1108
  handle: ProgrammaticAgentHandle,
@@ -1080,8 +1112,8 @@ export class ProgrammaticAgentRegistry {
1080
1112
  startedAt: string,
1081
1113
  usage: ThreadNote["usage"],
1082
1114
  opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
1083
- ): Promise<void> {
1084
- if (!this.writeThread) return;
1115
+ ): Promise<string | undefined> {
1116
+ if (!this.writeThread) return undefined;
1085
1117
  const thread: ThreadNote = {
1086
1118
  channel: handle.channel,
1087
1119
  name: handle.spec.name,
@@ -1107,12 +1139,18 @@ export class ProgrammaticAgentRegistry {
1107
1139
  ...(opts.phase ? { phase: opts.phase } : {}),
1108
1140
  };
1109
1141
  try {
1110
- await this.writeThread(thread);
1142
+ // The seam returns the WRITTEN note id (`{ id }`) for a durable transport; `void` for
1143
+ // one with no store. Surface it so the drain can set a RESOLVABLE callback
1144
+ // `source_thread` (agent#124). A missing id → undefined → the drain falls back to the
1145
+ // per-turn id.
1146
+ const written = await this.writeThread(thread);
1147
+ return written?.id;
1111
1148
  } catch (err) {
1112
1149
  console.error(
1113
1150
  `parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
1114
1151
  `(continuing): ${(err as Error).message}`,
1115
1152
  );
1153
+ return undefined;
1116
1154
  }
1117
1155
  }
1118
1156