@openparachute/agent 0.2.2 → 0.2.3-rc.11

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 (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
@@ -44,7 +44,7 @@
44
44
 
45
45
  import type { AgentSpec, AgentMode } from "../sandbox/types.ts";
46
46
  import { normalizeChannel } from "../sandbox/types.ts";
47
- import type { AgentBackend, AgentHandle, InterimTurnEvent, TurnSession } from "./types.ts";
47
+ import type { AgentBackend, AgentHandle, InterimTurnEvent, RunContext, TurnSession } from "./types.ts";
48
48
  import type { InboundAttachment } from "../transport.ts";
49
49
 
50
50
  /**
@@ -90,12 +90,15 @@ export type WriteOutbound = (
90
90
  reply: string,
91
91
  inReplyTo?: string,
92
92
  /**
93
- * The per-turn thread id this reply belongs to — the explicit definition→thread→message
94
- * link the outbound note carries (stamped into `metadata.thread`). For multi-threaded it
95
- * IS the per-fire thread note's leaf (an exact link); for single-threaded it's a per-turn
96
- * correlation id (the note's stable deterministic leaf is the def name single-threaded
97
- * outbound→note linkage by the stable path is a follow-up). INBOUND-note stamping is
98
- * deferred (those notes are externally written; see the PR notes).
93
+ * The RESOLVABLE, MODE-CORRECT thread id this reply belongs to — the explicit
94
+ * definition→thread→message link the outbound note carries (stamped into `metadata.thread`),
95
+ * mirroring the callback `source_thread` fix (agent#124/#163). For multi-threaded it IS the
96
+ * per-fire thread note's leaf (`Threads/<channel>/<id>`); for single-threaded it is the
97
+ * DETERMINISTIC thread-NOTE id (`Threads/<channel>/<name>`), STABLE across turns so an
98
+ * observer reading the outbound note's `metadata.thread` resolves the agent's ONE thread
99
+ * with `query-notes { id }`, instead of the per-turn correlation UUID that changed every
100
+ * run (the pre-#163 bug that looked like a fresh session each time). INBOUND-note stamping
101
+ * is deferred (those notes are externally written; see the PR notes).
99
102
  */
100
103
  threadId?: string,
101
104
  ) => Promise<{ id?: string } | void>;
@@ -174,8 +177,16 @@ export interface ThreadNote {
174
177
  * per channel, multi-threaded writes one per fire. A write failure is the implementation's
175
178
  * to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
176
179
  * the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
180
+ *
181
+ * RETURNS the WRITTEN thread-note's id (`{ id }`) so the drain can use it as a RESOLVABLE
182
+ * `source_thread` on the agent-to-agent callback (agent#124) — for BOTH modes, this is the
183
+ * actual note an orchestrator can pull with `query-notes { id }` (single-threaded: the
184
+ * deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
185
+ * `Threads/<safeChannel>/<uuid>` note). `void` is in the union (back-compat) — a transport
186
+ * with no durable store, or one that can't surface an id, returns it and the drain falls
187
+ * back to the per-turn id.
177
188
  */
178
- export type WriteThread = (thread: ThreadNote) => Promise<void>;
189
+ export type WriteThread = (thread: ThreadNote) => Promise<{ id?: string } | void>;
179
190
 
180
191
  /**
181
192
  * A callback delivered back to a SENDER's channel when a turn it requested finishes —
@@ -216,15 +227,16 @@ export interface CallbackMeta {
216
227
  /** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
217
228
  source_channel: string;
218
229
  /**
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).
230
+ * The WRITTEN thread-note id RESOLVABLE for BOTH modes (agent#124): an orchestrator can
231
+ * always pull the recipient's full thread record with `query-notes { id: source_thread }`,
232
+ * even on an error/empty/tool-only turn (the thread note is written BEFORE the outbound
233
+ * reply, so its id exists when there's no `source_message`).
234
+ * - multi-threaded: the per-fire note id (`Threads/<safeChannel>/<uuid>`).
235
+ * - single-threaded: the deterministic note id (`Threads/<safeChannel>/<safeName>`) NOT
236
+ * the per-turn correlation id (the pre-#124 bug: that correlation id wasn't the note leaf
237
+ * for single-threaded, so it couldn't be resolved).
238
+ * The drain sources this from {@link WriteThread}'s returned id; if the seam can't surface
239
+ * one (no durable store) it falls back to the per-turn id (still a stable provenance token).
228
240
  */
229
241
  source_thread: string;
230
242
  /**
@@ -293,6 +305,46 @@ function delay(ms: number): Promise<void> {
293
305
  return new Promise((resolve) => setTimeout(resolve, ms));
294
306
  }
295
307
 
308
+ /**
309
+ * The thread id to stamp into an OUTBOUND note's `metadata.thread` (agent#163) — the
310
+ * MODE-CORRECT, RESOLVABLE definition→thread→message link, mirroring the callback
311
+ * `source_thread` fix (agent#124):
312
+ *
313
+ * - SINGLE-THREADED — the resolvable thread-NOTE id (the deterministic
314
+ * `Threads/<safeChannel>/<safeName>` note), so an observer reading the outbound note's
315
+ * `metadata.thread` resolves the agent's ONE stable thread with `query-notes { id }`.
316
+ * The pre-#163 bug stamped the per-turn correlation UUID here, which changed every turn
317
+ * and resolved to nothing — misleading an observer into "a fresh session each run" when
318
+ * the single-threaded session is actually stable + resumed across turns.
319
+ * - MULTI-THREADED — the per-fire id (`turnThreadId`), which IS that fire's thread-note leaf
320
+ * (`Threads/<safeChannel>/<turnThreadId>`) — correct as-is: each fire is its own thread.
321
+ *
322
+ * Falls back to `turnThreadId` when no resolvable note id surfaced (no durable thread store
323
+ * wired, or the thread-note write failed) — never undefined, so the link is always stamped.
324
+ */
325
+ export function outboundThreadId(
326
+ multiThreaded: boolean,
327
+ turnThreadId: string,
328
+ threadNoteId: string | undefined,
329
+ ): string {
330
+ if (multiThreaded) return turnThreadId;
331
+ return threadNoteId ?? turnThreadId;
332
+ }
333
+
334
+ /**
335
+ * Derive the run-context `fired-by` provenance (agent#162) from an inbound message's
336
+ * `sender` (the note's `metadata.sender`). A SCHEDULED job fire stamps `runner:<jobId>`
337
+ * (the runner's sender provenance) → reported as the job id so the agent knows it's a cron
338
+ * fire; anything else (a human / a delegated agent message) → `interactive`. Absent sender →
339
+ * undefined (the run context omits `fired-by`).
340
+ */
341
+ export function runFiredBy(sender: string | undefined): string | undefined {
342
+ if (!sender) return undefined;
343
+ const m = /^runner:(.+)$/.exec(sender);
344
+ if (m) return `scheduled-job:${m[1]}`;
345
+ return "interactive";
346
+ }
347
+
296
348
  /** A queued inbound message awaiting its serial turn. */
297
349
  export interface QueuedMessage {
298
350
  /** The inbound text handed to the `claude -p` turn as the prompt. */
@@ -334,6 +386,13 @@ export interface QueuedMessage {
334
386
  * `Read` it. Absent/empty → no attachments (today's behavior unchanged).
335
387
  */
336
388
  attachments?: InboundAttachment[];
389
+ /**
390
+ * WHO/WHAT sent this inbound (the note's `metadata.sender`) — used ONLY to derive the
391
+ * run-context `fired-by` provenance the daemon injects into the turn (agent#162): a
392
+ * SCHEDULED job fire stamps `runner:<jobId>`, an interactive/delegated message stamps
393
+ * something else. Absent → the run context omits `fired-by`. Carries no routing meaning.
394
+ */
395
+ sender?: string;
337
396
  }
338
397
 
339
398
  /** A registered programmatic agent's live status (surfaced in /health + the list). */
@@ -797,7 +856,11 @@ export class ProgrammaticAgentRegistry {
797
856
  // start+end never double-count. Best-effort: a start-ensure write failure is logged
798
857
  // (inside recordThread) and the turn STILL runs — a missing/stale working note must
799
858
  // never strand the queue or skip the turn.
800
- await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
859
+ // Capture the start-ensure's WRITTEN thread-note id. For single-threaded this is the
860
+ // DETERMINISTIC `Threads/<safeChannel>/<safeName>` note (the same every turn) — so a
861
+ // resolvable thread id is available BEFORE the turn runs, for the failure-note +
862
+ // outbound `metadata.thread` stamping (agent#163), even on a turn that fails early.
863
+ const startThreadNoteId = await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
801
864
  threadId: turnThreadId,
802
865
  phase: "start",
803
866
  // NO session on the start-ensure: it runs BEFORE claude, so claude may never
@@ -807,6 +870,26 @@ export class ProgrammaticAgentRegistry {
807
870
  // the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
808
871
  // single-threaded resume turn the prior session is preserved by writeThread anyway.
809
872
  });
873
+ // The MODE-CORRECT, RESOLVABLE thread id stamped into outbound + failure notes
874
+ // (agent#163): single-threaded → the deterministic thread-NOTE id (stable across turns);
875
+ // multi-threaded → the per-fire `turnThreadId`. Computed once from the start-ensure id so
876
+ // every path (early failure, ok, outbound-failure) stamps the same resolvable link.
877
+ const outThreadId = outboundThreadId(multiThreaded, turnThreadId, startThreadNoteId);
878
+
879
+ // RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
880
+ // know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
881
+ // (`turnSession.resume`) or starts fresh, and WHY it fired (a scheduled job vs an
882
+ // interactive/delegated message, from the inbound sender). The backend prepends these as
883
+ // a labeled preamble so the agent stamps ACCURATE times instead of fabricating them.
884
+ // (DEFERRED: `priorTurnCount` — the rolling turn_count lives in the transport's thread
885
+ // note and isn't surfaced back to the drain; wiring it is a follow-up. Until then the
886
+ // preamble omits the `turn=N` line — the `now`/session/fired-by trio is the floor.)
887
+ const firedBy = runFiredBy(msg.sender);
888
+ const runContext: RunContext = {
889
+ now: startedAt,
890
+ session: turnSession.resume ? "resumed" : "new",
891
+ ...(firedBy ? { firedBy } : {}),
892
+ };
810
893
 
811
894
  let result;
812
895
  try {
@@ -821,6 +904,8 @@ export class ProgrammaticAgentRegistry {
821
904
  // Phase 1: inbound attachments → the programmatic backend stages them into the
822
905
  // agent's private workspace so the turn can Read them. Absent/empty → no staging.
823
906
  msg.attachments,
907
+ // agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
908
+ runContext,
824
909
  );
825
910
  } catch (err) {
826
911
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -835,7 +920,7 @@ export class ProgrammaticAgentRegistry {
835
920
  // thread note captures the turn outcome, so a failed turn is still a queryable
836
921
  // `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
837
922
  // a per-fire note).
838
- await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
923
+ const threadNoteId = await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
839
924
  threadId: turnThreadId,
840
925
  phase: "end",
841
926
  // No `result` (the backend threw) → NO session to persist. We never write a
@@ -845,11 +930,13 @@ export class ProgrammaticAgentRegistry {
845
930
  });
846
931
  this.emitTurnEvent(channel, { kind: "error", error: reason });
847
932
  // Post a user-facing failure note so the channel shows SOMETHING (not a silent
848
- // no-reply) — best-effort.
849
- await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
933
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
934
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, reason);
850
935
  // 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");
936
+ // hang waiting forever. No outbound note was produced, so no `source_message`; the
937
+ // RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
938
+ // still pull the recipient's thread on a no-reply turn (agent#124).
939
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
853
940
  continue;
854
941
  }
855
942
 
@@ -863,7 +950,7 @@ export class ProgrammaticAgentRegistry {
863
950
  // BOTH modes record the failed turn (status:error) on the thread note so a failure
864
951
  // always leaves a queryable trace (single-threaded upserts the rolling thread,
865
952
  // marking it errored; multi-threaded writes a per-fire status:error note).
866
- await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
953
+ const threadNoteId = await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
867
954
  threadId: turnThreadId,
868
955
  phase: "end",
869
956
  // Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
@@ -875,11 +962,12 @@ export class ProgrammaticAgentRegistry {
875
962
  });
876
963
  this.emitTurnEvent(channel, { kind: "error", error: result.error });
877
964
  // Post a user-facing failure note so the channel shows SOMETHING (not a silent
878
- // no-reply) — best-effort.
879
- await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
965
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
966
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, result.error);
880
967
  // 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");
968
+ // sub-task failed and can react. No delivered reply, so no `source_message`; the
969
+ // RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
970
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
883
971
  continue;
884
972
  }
885
973
 
@@ -891,7 +979,11 @@ export class ProgrammaticAgentRegistry {
891
979
  // multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
892
980
  // logged + the turn still resolves (we never re-run a `claude -p` turn — that would
893
981
  // burn quota for a duplicate).
894
- await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
982
+ // Capture the WRITTEN thread-note id the RESOLVABLE `source_thread` for the callback
983
+ // (agent#124). The same note id is reused for the outbound-failure re-record below
984
+ // (sameTurn → same note), so a callback on either terminal path points at a pullable
985
+ // thread record.
986
+ let threadNoteId = await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
895
987
  threadId: turnThreadId,
896
988
  phase: "end",
897
989
  // Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
@@ -915,7 +1007,12 @@ export class ProgrammaticAgentRegistry {
915
1007
  channel,
916
1008
  result.reply,
917
1009
  msg.inReplyTo,
918
- turnThreadId,
1010
+ // agent#163: stamp the MODE-CORRECT, RESOLVABLE thread id into the outbound note's
1011
+ // `metadata.thread` — single-threaded → the deterministic thread-NOTE id (stable
1012
+ // across turns; an observer resolves the ONE thread), multi-threaded → the per-fire
1013
+ // id. NOT the per-turn correlation UUID (the pre-#163 bug that looked like a fresh
1014
+ // session every run).
1015
+ outThreadId,
919
1016
  );
920
1017
  if (delivered.ok) sourceMessage = delivered.noteId;
921
1018
  if (!delivered.ok) {
@@ -938,7 +1035,9 @@ export class ProgrammaticAgentRegistry {
938
1035
  // `sameTurn` so this updates the note the `ok` record above just wrote (one
939
1036
  // note, no turn_count double-count) rather than minting a duplicate / advancing
940
1037
  // the count (the FIX-1 re-record bug the reviewer caught).
941
- await this.recordThread(
1038
+ // Re-record returns the SAME note's id (sameTurn upsert / same per-fire note) — use
1039
+ // it as the callback `source_thread` (agent#124), falling back to the ok-record id.
1040
+ threadNoteId = (await this.recordThread(
942
1041
  handle,
943
1042
  msg,
944
1043
  "error",
@@ -955,7 +1054,7 @@ export class ProgrammaticAgentRegistry {
955
1054
  // write failed. Only claude's echoed id (FIX 2), never the passed uuid.
956
1055
  ...(result.sessionId ? { session: result.sessionId } : {}),
957
1056
  },
958
- );
1057
+ )) ?? threadNoteId;
959
1058
  this.emitTurnEvent(channel, {
960
1059
  kind: "error",
961
1060
  error: `reply produced but not saved: ${delivered.error}`,
@@ -963,8 +1062,8 @@ export class ProgrammaticAgentRegistry {
963
1062
  // CALLBACK as status:error — the reply was produced but NOT delivered, so the
964
1063
  // turn did not truly succeed; the orchestrator must learn that. No `source_message`
965
1064
  // (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");
1065
+ // note for an operator to recover — pull it via the RESOLVABLE `source_thread`.
1066
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
968
1067
  continue;
969
1068
  }
970
1069
  }
@@ -976,8 +1075,10 @@ export class ProgrammaticAgentRegistry {
976
1075
  // (empty/tool-only turn → clean resolve, no note expected).
977
1076
  this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
978
1077
  // 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);
1078
+ // delivered reply note (when there was one) the orchestrator pulls the full result from;
1079
+ // `source_thread` (the WRITTEN thread-note id, agent#124) is the RESOLVABLE pull-link in
1080
+ // both modes, including an empty/tool-only turn where there's no `sourceMessage`.
1081
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage, threadNoteId);
981
1082
  }
982
1083
  }
983
1084
 
@@ -1013,6 +1114,7 @@ export class ProgrammaticAgentRegistry {
1013
1114
  turnThreadId: string,
1014
1115
  status: "ok" | "error",
1015
1116
  sourceMessage?: string,
1117
+ sourceThreadId?: string,
1016
1118
  ): Promise<void> {
1017
1119
  // Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
1018
1120
  if (!this.writeCallback) return;
@@ -1041,11 +1143,17 @@ export class ProgrammaticAgentRegistry {
1041
1143
  // source_message are echoed/included only when present. The daemon's WriteCallback
1042
1144
  // wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
1043
1145
  // — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
1146
+ //
1147
+ // `source_thread` is the WRITTEN thread-note id (agent#124) — RESOLVABLE for BOTH modes
1148
+ // (`query-notes { id: source_thread }`), available even on an error/empty/tool-only turn
1149
+ // (the thread note is written before the outbound). Fall back to the per-turn id only when
1150
+ // the thread seam surfaced none (no durable store / a write failure) — still a stable
1151
+ // provenance token, just not a pullable note.
1044
1152
  const meta: CallbackMeta = {
1045
1153
  callback: "true",
1046
1154
  status,
1047
1155
  source_channel: handle.channel,
1048
- source_thread: turnThreadId,
1156
+ source_thread: sourceThreadId ?? turnThreadId,
1049
1157
  ...(sourceMessage ? { source_message: sourceMessage } : {}),
1050
1158
  ...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
1051
1159
  delegation_depth: String(incomingDepth + 1),
@@ -1071,6 +1179,11 @@ export class ProgrammaticAgentRegistry {
1071
1179
  * agent name (single-threaded's thread is "named after the definition"). Best-effort: a
1072
1180
  * write failure is LOGGED, never thrown out — a missing thread note must not strand the
1073
1181
  * queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
1182
+ *
1183
+ * RETURNS the WRITTEN thread-note id so the drain can use it as a RESOLVABLE
1184
+ * `source_thread` on the agent-to-agent callback (agent#124), for BOTH modes. `undefined`
1185
+ * when no sink is wired, the write failed, or the seam surfaced no id — the drain then
1186
+ * falls back to the per-turn id.
1074
1187
  */
1075
1188
  private async recordThread(
1076
1189
  handle: ProgrammaticAgentHandle,
@@ -1080,8 +1193,8 @@ export class ProgrammaticAgentRegistry {
1080
1193
  startedAt: string,
1081
1194
  usage: ThreadNote["usage"],
1082
1195
  opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
1083
- ): Promise<void> {
1084
- if (!this.writeThread) return;
1196
+ ): Promise<string | undefined> {
1197
+ if (!this.writeThread) return undefined;
1085
1198
  const thread: ThreadNote = {
1086
1199
  channel: handle.channel,
1087
1200
  name: handle.spec.name,
@@ -1107,12 +1220,18 @@ export class ProgrammaticAgentRegistry {
1107
1220
  ...(opts.phase ? { phase: opts.phase } : {}),
1108
1221
  };
1109
1222
  try {
1110
- await this.writeThread(thread);
1223
+ // The seam returns the WRITTEN note id (`{ id }`) for a durable transport; `void` for
1224
+ // one with no store. Surface it so the drain can set a RESOLVABLE callback
1225
+ // `source_thread` (agent#124). A missing id → undefined → the drain falls back to the
1226
+ // per-turn id.
1227
+ const written = await this.writeThread(thread);
1228
+ return written?.id;
1111
1229
  } catch (err) {
1112
1230
  console.error(
1113
1231
  `parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
1114
1232
  `(continuing): ${(err as Error).message}`,
1115
1233
  );
1234
+ return undefined;
1116
1235
  }
1117
1236
  }
1118
1237
 
@@ -84,6 +84,43 @@ export interface TurnSession {
84
84
  resume: boolean;
85
85
  }
86
86
 
87
+ /**
88
+ * RUN CONTEXT for one turn (agent#162) — the runtime facts a programmatic `claude -p` turn
89
+ * otherwise has NO way to know, so an agent stops FABRICATING them. A headless `-p` turn has
90
+ * no clock and no notion of "which run this is": uni-weaver was openly inventing report
91
+ * timestamps (a fixed `10:05` slot, the date "derived from context") because it couldn't read
92
+ * a real clock mid-run. The daemon KNOWS these facts at dispatch time, so it injects them into
93
+ * the turn (a concise, clearly-labeled preamble the agent reads) rather than letting the agent
94
+ * guess. Cheap, and it removes a whole class of fabricated-time confusion.
95
+ *
96
+ * The backend renders this as a SHORT preamble prepended to the turn message — it never
97
+ * mangles the agent's own system-prompt semantics. ADDITIVE: a caller that omits it leaves the
98
+ * turn message exactly as before.
99
+ */
100
+ export interface RunContext {
101
+ /** The REAL wall-clock at dispatch (ISO 8601) — the authoritative clock the turn lacks. */
102
+ now: string;
103
+ /**
104
+ * Whether this turn CONTINUES a prior conversation (`resumed`, single-threaded turn 2+) or
105
+ * STARTS a fresh one (`new`, the first turn / every multi-threaded fire) — the cheap "which
106
+ * run is this" signal the daemon already resolved (`TurnSession.resume`).
107
+ */
108
+ session: "new" | "resumed";
109
+ /**
110
+ * WHY this turn is running (provenance): a SCHEDULED job fire stamps `runner:<jobId>` (the
111
+ * runner's sender provenance) → reported as the job id; anything else is an interactive /
112
+ * delegated message → `interactive`. Lets a scheduled agent know it's a cron fire vs a live
113
+ * reply. Absent when the inbound carried no sender.
114
+ */
115
+ firedBy?: string;
116
+ /**
117
+ * The thread's COMPLETED turn count BEFORE this turn (single-threaded's rolling counter;
118
+ * 0 on the first turn). Best-effort — omitted when the daemon can't cheaply resolve it
119
+ * (no durable thread store). So the agent can stamp "turn N" accurately.
120
+ */
121
+ priorTurnCount?: number;
122
+ }
123
+
87
124
  /**
88
125
  * An opaque handle to a started agent, returned by {@link AgentBackend.start} and
89
126
  * passed back to `deliver`/`stop`/`status`. The only field the seam itself depends
@@ -201,6 +238,12 @@ export interface AgentBackend {
201
238
  * a safe basename) before the turn and appends a workspace-relative pointer to the
202
239
  * prompt, so the `claude -p` turn can `Read` them. ADDITIVE — absent/empty → no
203
240
  * staging, the turn behaves exactly as before.
241
+ *
242
+ * `runContext` (optional, agent#162) is the runtime context the daemon knows but a headless
243
+ * `-p` turn can't (the real wall-clock, whether this run is new vs resumed, why it fired).
244
+ * The programmatic backend prepends it as a concise, clearly-labeled preamble to the turn
245
+ * message so the agent stamps ACCURATE times instead of fabricating them. ADDITIVE — omitted
246
+ * → the turn message is exactly as before.
204
247
  */
205
248
  deliver(
206
249
  handle: AgentHandle,
@@ -208,6 +251,7 @@ export interface AgentBackend {
208
251
  session: TurnSession,
209
252
  onInterim?: InterimSink,
210
253
  attachments?: InboundAttachment[],
254
+ runContext?: RunContext,
211
255
  ): Promise<DeliverResult>;
212
256
 
213
257
  /**