@openparachute/agent 0.2.3-rc.10 → 0.2.3-rc.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.10",
3
+ "version": "0.2.3-rc.12",
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",
@@ -85,6 +85,7 @@ import type {
85
85
  DeliverResult,
86
86
  DeliverUsage,
87
87
  InterimSink,
88
+ RunContext,
88
89
  TurnSession,
89
90
  } from "./types.ts";
90
91
 
@@ -327,6 +328,32 @@ export function buildProgrammaticClaudeArgs(opts: {
327
328
  return argv;
328
329
  }
329
330
 
331
+ /**
332
+ * Render the {@link RunContext} as a concise, clearly-LABELED preamble to PREPEND to a turn's
333
+ * message (agent#162). A headless `claude -p` turn has no clock + no notion of which run it is,
334
+ * so the daemon hands it these facts (the real wall-clock, new-vs-resumed, why it fired, the
335
+ * prior turn count) — the agent then stamps ACCURATE times instead of fabricating them.
336
+ *
337
+ * It is a single fenced block clearly marked as daemon-injected runtime context (NOT the
338
+ * agent's own system prompt — that's untouched), then a blank line, then the real message. The
339
+ * `now` is always present; the rest are appended only when known. Returns the message UNCHANGED
340
+ * when `rc` is absent (additive — no behavior change for a caller that doesn't pass one).
341
+ */
342
+ export function renderRunContext(message: string, rc: RunContext | undefined): string {
343
+ if (!rc) return message;
344
+ const parts: string[] = [`now=${rc.now}`, `session=${rc.session}`];
345
+ if (typeof rc.priorTurnCount === "number" && rc.priorTurnCount >= 0) {
346
+ // The NUMBER of this turn (1-based) = completed turns + 1 — what an agent stamps as "turn N".
347
+ parts.push(`turn=${rc.priorTurnCount + 1}`);
348
+ }
349
+ if (rc.firedBy) parts.push(`fired-by=${rc.firedBy}`);
350
+ const preamble =
351
+ `[Run context — injected by the agent daemon (this is the real runtime state, NOT your ` +
352
+ `system prompt). Use these for any timestamp/clock or "which run is this" reasoning instead ` +
353
+ `of guessing: ${parts.join(", ")}]`;
354
+ return `${preamble}\n\n${message}`;
355
+ }
356
+
330
357
  /** Read the full text of a (possibly null) byte stream; null/error → "". */
331
358
  async function drainStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
332
359
  if (!stream) return "";
@@ -414,6 +441,8 @@ export class ProgrammaticBackend implements AgentBackend {
414
441
  session: TurnSession,
415
442
  onInterim?: InterimSink,
416
443
  attachments?: InboundAttachment[],
444
+ runContext?: RunContext,
445
+ subjectDossier?: string,
417
446
  ): Promise<DeliverResult> {
418
447
  const spec = handle.spec;
419
448
  if (!spec) {
@@ -537,13 +566,18 @@ export class ProgrammaticBackend implements AgentBackend {
537
566
  // per-agent even when the working dir is shared. Best-effort + isolated: a single
538
567
  // attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
539
568
  // + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
540
- let turnMessage = message;
569
+ // RUN CONTEXT (agent#162): prepend the daemon-injected runtime preamble (the real
570
+ // wall-clock + new/resumed + why-it-fired) so the headless turn reads ACCURATE facts
571
+ // instead of fabricating a clock. Done FIRST so the preamble sits at the very top of the
572
+ // prompt; attachments append after the (already-prefixed) message. Absent runContext →
573
+ // the message is unchanged (additive).
574
+ let turnMessage = renderRunContext(message, runContext);
541
575
  if (attachments && attachments.length > 0) {
542
576
  const staged = await this.stageAttachments(workspace, attachments, vaultArg);
543
577
  if (staged.length > 0) {
544
578
  const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
545
579
  turnMessage =
546
- `${message}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
580
+ `${turnMessage}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
547
581
  }
548
582
  }
549
583
 
@@ -554,10 +588,22 @@ export class ProgrammaticBackend implements AgentBackend {
554
588
  // Unset → no flag, no file (today's behavior unchanged). The `-file` form is
555
589
  // robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
556
590
  // lifecycle is tied to the workspace (like .mcp.json) — it disappears with it.
591
+ //
592
+ // COMPOSED PROMPT (roles×threads NOW slice): when a `subjectDossier` is handed in,
593
+ // the agent's stable ROLE (the spec's systemPrompt) is composed with the per-thread
594
+ // subject context as `roleBody + "\n\n---\n\n" + dossier`. The role stays constant
595
+ // across a role's threads; the subject-specific dossier layers on top. Absent/empty
596
+ // dossier → the role body is written VERBATIM (byte-identical to HEAD — the
597
+ // null-subject invariant; the run-context preamble stays on the MESSAGE, not here).
557
598
  let systemPromptFile: string | undefined;
558
599
  if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {
600
+ const roleBody = spec.systemPrompt;
601
+ const composed =
602
+ typeof subjectDossier === "string" && subjectDossier.length > 0
603
+ ? `${roleBody}\n\n---\n\n${subjectDossier}`
604
+ : roleBody;
559
605
  systemPromptFile = join(workspace, "system-prompt.txt");
560
- writeFileSync(systemPromptFile, spec.systemPrompt, { mode: 0o600 });
606
+ writeFileSync(systemPromptFile, composed, { mode: 0o600 });
561
607
  }
562
608
 
563
609
  // The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
@@ -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>;
@@ -302,6 +305,46 @@ function delay(ms: number): Promise<void> {
302
305
  return new Promise((resolve) => setTimeout(resolve, ms));
303
306
  }
304
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
+
305
348
  /** A queued inbound message awaiting its serial turn. */
306
349
  export interface QueuedMessage {
307
350
  /** The inbound text handed to the `claude -p` turn as the prompt. */
@@ -343,6 +386,23 @@ export interface QueuedMessage {
343
386
  * `Read` it. Absent/empty → no attachments (today's behavior unchanged).
344
387
  */
345
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;
396
+ /**
397
+ * The THREAD SUBJECT (roles×threads NOW slice) — rides from the inbound note's
398
+ * `metadata.subject`, through `contextFor.emit` (daemon.ts), onto this queue item.
399
+ * In the NOW slice it carries NO routing meaning — the drain stays per-CHANNEL serial,
400
+ * NOT per-subject — but it is threaded through so the composed prompt can fold in a
401
+ * subject dossier and the NEXT slice (#120 thread routing + per-thread session
402
+ * continuity) can key off it without re-plumbing. Absent → no subject (today's behavior;
403
+ * the weave path is untouched). Carries no routing meaning in NOW.
404
+ */
405
+ subject?: string;
346
406
  }
347
407
 
348
408
  /** A registered programmatic agent's live status (surfaced in /health + the list). */
@@ -428,6 +488,16 @@ export class ProgrammaticAgentRegistry {
428
488
  * Unwired → reset is a clean no-op beyond returning that the agent exists.
429
489
  */
430
490
  private readonly clearSession?: (channel: string, name: string) => Promise<void>;
491
+ /**
492
+ * Optional pre-turn SUBJECT-DOSSIER read (roles×threads NOW slice) — per-thread
493
+ * context for the current subject, folded into the composed system prompt (the
494
+ * programmatic backend's `roleBody + "\n\n---\n\n" + dossier`). Read in {@link drain}
495
+ * keyed on the inbound message's subject. UNWIRED in the NOW slice (the default registry
496
+ * does NOT set it — there's no dossier-note convention yet), so every turn composes the
497
+ * role body verbatim and the null-subject path is byte-identical to HEAD. The seam is
498
+ * here so a future dossier source threads in without re-plumbing `deliver`.
499
+ */
500
+ private readonly readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
431
501
  /** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
432
502
  private readonly outboundRetryBaseMs: number;
433
503
 
@@ -441,6 +511,11 @@ export class ProgrammaticAgentRegistry {
441
511
  readSession?: (channel: string, name: string) => Promise<string | undefined>;
442
512
  /** Clear the persisted thread-note session (the per-agent restart / reset). */
443
513
  clearSession?: (channel: string, name: string) => Promise<void>;
514
+ /**
515
+ * Read the per-thread subject dossier (roles×threads NOW slice). Optional + UNWIRED
516
+ * by default — the composed prompt is the role body verbatim until a dossier source exists.
517
+ */
518
+ readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
444
519
  /** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
445
520
  outboundRetryBaseMs?: number;
446
521
  }) {
@@ -451,6 +526,7 @@ export class ProgrammaticAgentRegistry {
451
526
  if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
452
527
  if (deps.readSession) this.readSession = deps.readSession;
453
528
  if (deps.clearSession) this.clearSession = deps.clearSession;
529
+ if (deps.readSubjectDossier) this.readSubjectDossier = deps.readSubjectDossier;
454
530
  this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
455
531
  }
456
532
 
@@ -806,7 +882,11 @@ export class ProgrammaticAgentRegistry {
806
882
  // start+end never double-count. Best-effort: a start-ensure write failure is logged
807
883
  // (inside recordThread) and the turn STILL runs — a missing/stale working note must
808
884
  // never strand the queue or skip the turn.
809
- await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
885
+ // Capture the start-ensure's WRITTEN thread-note id. For single-threaded this is the
886
+ // DETERMINISTIC `Threads/<safeChannel>/<safeName>` note (the same every turn) — so a
887
+ // resolvable thread id is available BEFORE the turn runs, for the failure-note +
888
+ // outbound `metadata.thread` stamping (agent#163), even on a turn that fails early.
889
+ const startThreadNoteId = await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
810
890
  threadId: turnThreadId,
811
891
  phase: "start",
812
892
  // NO session on the start-ensure: it runs BEFORE claude, so claude may never
@@ -816,6 +896,44 @@ export class ProgrammaticAgentRegistry {
816
896
  // the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
817
897
  // single-threaded resume turn the prior session is preserved by writeThread anyway.
818
898
  });
899
+ // The MODE-CORRECT, RESOLVABLE thread id stamped into outbound + failure notes
900
+ // (agent#163): single-threaded → the deterministic thread-NOTE id (stable across turns);
901
+ // multi-threaded → the per-fire `turnThreadId`. Computed once from the start-ensure id so
902
+ // every path (early failure, ok, outbound-failure) stamps the same resolvable link.
903
+ const outThreadId = outboundThreadId(multiThreaded, turnThreadId, startThreadNoteId);
904
+
905
+ // RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
906
+ // know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
907
+ // (`turnSession.resume`) or starts fresh, and WHY it fired (a scheduled job vs an
908
+ // interactive/delegated message, from the inbound sender). The backend prepends these as
909
+ // a labeled preamble so the agent stamps ACCURATE times instead of fabricating them.
910
+ // (DEFERRED: `priorTurnCount` — the rolling turn_count lives in the transport's thread
911
+ // note and isn't surfaced back to the drain; wiring it is a follow-up. Until then the
912
+ // preamble omits the `turn=N` line — the `now`/session/fired-by trio is the floor.)
913
+ const firedBy = runFiredBy(msg.sender);
914
+ const runContext: RunContext = {
915
+ now: startedAt,
916
+ session: turnSession.resume ? "resumed" : "new",
917
+ ...(firedBy ? { firedBy } : {}),
918
+ };
919
+
920
+ // SUBJECT DOSSIER (roles×threads NOW slice): when the inbound carries a subject AND a
921
+ // dossier source is wired, resolve the per-thread context the backend folds into the
922
+ // composed system prompt. UNWIRED by default (no dossier-note convention yet) and
923
+ // no-subject always → `undefined`, so the system prompt is the role body verbatim and
924
+ // the null-subject path is byte-identical to HEAD. Best-effort: a dossier read failure
925
+ // logs + the turn still runs (role-only), never strands the queue.
926
+ let subjectDossier: string | undefined;
927
+ if (msg.subject && this.readSubjectDossier) {
928
+ try {
929
+ subjectDossier = await this.readSubjectDossier(handle.channel, msg.subject);
930
+ } catch (err) {
931
+ console.error(
932
+ `parachute-agent: subject-dossier read for channel "${channel}" ` +
933
+ `subject "${msg.subject}" failed (turn runs role-only): ${(err as Error).message}`,
934
+ );
935
+ }
936
+ }
819
937
 
820
938
  let result;
821
939
  try {
@@ -830,6 +948,11 @@ export class ProgrammaticAgentRegistry {
830
948
  // Phase 1: inbound attachments → the programmatic backend stages them into the
831
949
  // agent's private workspace so the turn can Read them. Absent/empty → no staging.
832
950
  msg.attachments,
951
+ // agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
952
+ runContext,
953
+ // roles×threads NOW slice: the per-thread subject dossier, folded into the composed
954
+ // system prompt. Undefined (no subject / unwired source) → role body verbatim.
955
+ subjectDossier,
833
956
  );
834
957
  } catch (err) {
835
958
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -854,8 +977,8 @@ export class ProgrammaticAgentRegistry {
854
977
  });
855
978
  this.emitTurnEvent(channel, { kind: "error", error: reason });
856
979
  // Post a user-facing failure note so the channel shows SOMETHING (not a silent
857
- // no-reply) — best-effort.
858
- await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
980
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
981
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, reason);
859
982
  // CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
860
983
  // hang waiting forever. No outbound note was produced, so no `source_message`; the
861
984
  // RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
@@ -886,8 +1009,8 @@ export class ProgrammaticAgentRegistry {
886
1009
  });
887
1010
  this.emitTurnEvent(channel, { kind: "error", error: result.error });
888
1011
  // Post a user-facing failure note so the channel shows SOMETHING (not a silent
889
- // no-reply) — best-effort.
890
- await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
1012
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
1013
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, result.error);
891
1014
  // CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
892
1015
  // sub-task failed and can react. No delivered reply, so no `source_message`; the
893
1016
  // RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
@@ -931,7 +1054,12 @@ export class ProgrammaticAgentRegistry {
931
1054
  channel,
932
1055
  result.reply,
933
1056
  msg.inReplyTo,
934
- turnThreadId,
1057
+ // agent#163: stamp the MODE-CORRECT, RESOLVABLE thread id into the outbound note's
1058
+ // `metadata.thread` — single-threaded → the deterministic thread-NOTE id (stable
1059
+ // across turns; an observer resolves the ONE thread), multi-threaded → the per-fire
1060
+ // id. NOT the per-turn correlation UUID (the pre-#163 bug that looked like a fresh
1061
+ // session every run).
1062
+ outThreadId,
935
1063
  );
936
1064
  if (delivered.ok) sourceMessage = delivered.noteId;
937
1065
  if (!delivered.ok) {
@@ -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,20 @@ 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.
247
+ *
248
+ * `subjectDossier` (optional, roles×threads NOW slice) is per-THREAD context for the
249
+ * agent's CURRENT subject — folded into the SYSTEM prompt (NOT the message): the programmatic
250
+ * backend writes `roleBody + "\n\n---\n\n" + dossier` to the per-turn `system-prompt.txt` when
251
+ * present, so the agent's ROLE (the spec's systemPrompt) stays stable across threads while the
252
+ * subject-specific context layers on top. The run-context preamble is unaffected (it stays on
253
+ * the MESSAGE). ADDITIVE — omitted/empty → the system prompt is the role body verbatim
254
+ * (byte-identical to HEAD; the null-subject invariant).
204
255
  */
205
256
  deliver(
206
257
  handle: AgentHandle,
@@ -208,6 +259,8 @@ export interface AgentBackend {
208
259
  session: TurnSession,
209
260
  onInterim?: InterimSink,
210
261
  attachments?: InboundAttachment[],
262
+ runContext?: RunContext,
263
+ subjectDossier?: string,
211
264
  ): Promise<DeliverResult>;
212
265
 
213
266
  /**
package/src/daemon.ts CHANGED
@@ -342,6 +342,13 @@ export function contextFor(
342
342
  // Phase 1: carry inbound file attachments through to the turn (the programmatic
343
343
  // backend stages them into the agent's private workspace so the turn can Read them).
344
344
  ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
345
+ // agent#162: carry the inbound sender so the drain can derive the run-context
346
+ // `fired-by` (a scheduled `runner:<jobId>` fire vs an interactive/delegated message).
347
+ ...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
348
+ // roles×threads NOW slice: carry the thread subject through to the drain so the
349
+ // composed prompt can fold in a subject dossier (and the NEXT slice can route by
350
+ // it). No routing meaning yet. Absent → unchanged (the weave path is untouched).
351
+ ...(msg.meta?.subject ? { subject: msg.meta.subject } : {}),
345
352
  });
346
353
  return;
347
354
  }
@@ -366,6 +373,13 @@ export function contextFor(
366
373
  // Phase 1: carry inbound attachments through the pending buffer too, so a turn
367
374
  // that runs on register() still stages them.
368
375
  ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
376
+ // agent#162: carry the sender through the pending buffer too, so a turn that runs on
377
+ // register() still derives the right run-context `fired-by`.
378
+ ...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
379
+ // roles×threads NOW slice: carry the thread subject through the pending buffer too,
380
+ // so a turn that runs on register() still folds in its subject dossier. No routing
381
+ // meaning yet. Absent → unchanged.
382
+ ...(msg.meta?.subject ? { subject: msg.meta.subject } : {}),
369
383
  });
370
384
  if (outcome === "queued") return;
371
385
  // outcome === "unknown" — not an expected programmatic channel. It may still be a
@@ -1809,7 +1823,12 @@ export function createFetchHandler(
1809
1823
  if (!transport) {
1810
1824
  return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
1811
1825
  }
1812
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
1826
+ // roles×threads NOW slice: thread the job's subject (absent → no subject).
1827
+ await transport.injectInbound({
1828
+ content: job.message,
1829
+ sender: `runner:${job.id}`,
1830
+ ...(job.subject ? { subject: job.subject } : {}),
1831
+ });
1813
1832
  return json({ ok: true, id, status: "ok" });
1814
1833
  } catch (err) {
1815
1834
  return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
@@ -3375,7 +3394,14 @@ function main(): void {
3375
3394
  if (!transport) {
3376
3395
  throw new Error(`channel "${job.channel}" is not a live vault channel`);
3377
3396
  }
3378
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
3397
+ // roles×threads NOW slice: thread the job's subject onto the inbound note so the
3398
+ // turn's composed prompt can fold in its dossier. Absent → no subject (the weave
3399
+ // job carries none → byte-identical to HEAD).
3400
+ await transport.injectInbound({
3401
+ content: job.message,
3402
+ sender: `runner:${job.id}`,
3403
+ ...(job.subject ? { subject: job.subject } : {}),
3404
+ });
3379
3405
  },
3380
3406
  // Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
3381
3407
  // by its vault note id). A job loaded from the store always carries `noteId`.
package/src/jobs.ts CHANGED
@@ -65,6 +65,13 @@ export interface Job {
65
65
  lastStatus?: string;
66
66
  /** ISO timestamp of the next scheduled fire — COMPUTED IN MEMORY, never persisted. */
67
67
  nextRunAt?: string;
68
+ /**
69
+ * The THREAD SUBJECT a fire of this job carries (roles×threads NOW slice). When set,
70
+ * the runner stamps it onto the inbound note (`metadata.subject`) so the turn's composed
71
+ * prompt + (NEXT) thread routing can read it. Optional; absent → today's behavior (the
72
+ * weave job carries none — its fire is byte-identical to HEAD).
73
+ */
74
+ subject?: string;
68
75
  }
69
76
 
70
77
  /** A slug: alphanumeric, dash, underscore (same shape as channel names). */
@@ -202,6 +209,7 @@ export class VaultJobStore {
202
209
  createdAt: n.createdAt ?? "",
203
210
  ...(n.lastRunAt ? { lastRunAt: n.lastRunAt } : {}),
204
211
  ...(n.lastStatus ? { lastStatus: n.lastStatus } : {}),
212
+ ...(n.subject ? { subject: n.subject } : {}),
205
213
  });
206
214
  }
207
215
  }
@@ -229,6 +237,7 @@ export class VaultJobStore {
229
237
  createdAt: job.createdAt,
230
238
  ...(job.lastRunAt ? { lastRunAt: job.lastRunAt } : {}),
231
239
  ...(job.lastStatus ? { lastStatus: job.lastStatus } : {}),
240
+ ...(job.subject ? { subject: job.subject } : {}),
232
241
  });
233
242
  return { ...job, noteId }; // id stays the slug; noteId addresses the persisted note.
234
243
  }
@@ -380,3 +380,41 @@ export function normalizeChannel(ch: AgentChannel): { name: string; access: "rea
380
380
  if (typeof ch === "string") return { name: ch, access: "write" };
381
381
  return { name: ch.name, access: ch.access ?? "write" };
382
382
  }
383
+
384
+ /**
385
+ * Collapse a string to a flat, path-safe slug — every char outside
386
+ * `[a-zA-Z0-9_-]` becomes `-`. This MIRRORS the channel/name sanitizer used on
387
+ * the vault note paths (`vault.ts:746`, `:822-823`) so a value sanitized here
388
+ * can be used as a path leaf or a `--session-id` segment without re-escaping.
389
+ * Pure + idempotent. Exported so the NEXT slice (per-thread workspace paths,
390
+ * spec §G) reuses this exact sanitizer rather than re-implementing it (drift risk).
391
+ */
392
+ export function slug(raw: string): string {
393
+ return raw.replace(/[^a-zA-Z0-9_-]/g, "-");
394
+ }
395
+
396
+ /**
397
+ * The THREAD identity key for one (agent, subject) pair — the roles×threads NOW
398
+ * slice (design team-vault `Strategy/2026-06-29-agents-roles-threads`).
399
+ *
400
+ * - No subject (absent / empty / whitespace-only) → the BARE `specName`. This is
401
+ * the back-compat path: every agent today (incl. the weave) carries no subject,
402
+ * so `threadKey` returns exactly the spec name and every downstream
403
+ * path/key/workspace is byte-identical to HEAD.
404
+ * - A non-empty subject → `${specName}--${slug(subject)}`. The `--` separator and
405
+ * the `slug()` sanitize make the key safe to use as a vault path leaf or a
406
+ * Claude `--session-id` segment.
407
+ *
408
+ * SECURITY: `subject` is UNTRUSTED input that later becomes a path leaf, so it is
409
+ * run through {@link slug} here — `../`, spaces, slashes, and other path-dangerous
410
+ * chars collapse to `-`, so a subject can never traverse the path hierarchy.
411
+ * `specName` is NOT sanitized here (it's already a sanitized agent/channel name
412
+ * upstream); only the untrusted subject is.
413
+ *
414
+ * NOTE: subject is PER-THREAD — it rides on the inbound message, NOT on the
415
+ * {@link AgentSpec} (which is per-AGENT). Keep it off the spec.
416
+ */
417
+ export function threadKey(specName: string, subject?: string): string {
418
+ const trimmed = subject?.trim();
419
+ return trimmed ? `${specName}--${slug(trimmed)}` : specName;
420
+ }
@@ -161,6 +161,11 @@ export interface JobNote {
161
161
  lastRunAt?: string;
162
162
  /** "ok" / "error: …" from the most recent fire. */
163
163
  lastStatus?: string;
164
+ /**
165
+ * The THREAD SUBJECT a fire carries (roles×threads NOW slice) — read back from
166
+ * `metadata.subject`. Optional; absent → today's behavior (no subject).
167
+ */
168
+ subject?: string;
164
169
  }
165
170
 
166
171
  /** The metadata payload written for a job note (all string-typed, per the vault). */
@@ -176,6 +181,13 @@ export interface JobNoteMetadata {
176
181
  createdAt: string;
177
182
  lastRunAt?: string;
178
183
  lastStatus?: string;
184
+ /**
185
+ * The THREAD SUBJECT a fire of this job carries (roles×threads NOW slice) — stamped
186
+ * onto the inbound note the runner injects, so the turn's composed prompt + (NEXT)
187
+ * thread routing can read it. Optional; absent → today's behavior (the weave job
188
+ * carries none).
189
+ */
190
+ subject?: string;
179
191
  }
180
192
 
181
193
  /**
@@ -761,11 +773,14 @@ export class VaultTransport implements Transport {
761
773
  // Thread the reply to the inbound note id when the bridge passes it through.
762
774
  const inReplyTo = args.meta?.in_reply_to;
763
775
  if (inReplyTo) metadata.in_reply_to = inReplyTo;
764
- // The explicit definition→thread→message link: stamp the outbound note with its thread
765
- // id (the programmatic worker passes the per-turn thread id through `meta.thread`). For a
766
- // multi-threaded turn this IS the per-fire `#agent/thread` note's leaf; for a
767
- // single-threaded turn it's a per-turn correlation id. INBOUND-note stamping is deferred
768
- // (those notes are written externally, before the turn knows its thread).
776
+ // The explicit definition→thread→message link: stamp the outbound note with its
777
+ // RESOLVABLE, mode-correct thread id (the programmatic worker passes it through
778
+ // `meta.thread`, agent#163). For a multi-threaded turn this IS the per-fire `#agent/thread`
779
+ // note's leaf; for a single-threaded turn it's the DETERMINISTIC thread-NOTE id
780
+ // (`Threads/<channel>/<name>`), STABLE across turns so an observer resolves the agent's
781
+ // ONE thread from `metadata.thread`, not a per-turn UUID that changed every run.
782
+ // INBOUND-note stamping is deferred (those notes are written externally, before the turn
783
+ // knows its thread).
769
784
  const threadId = args.meta?.thread;
770
785
  if (threadId) metadata.thread = threadId;
771
786
 
@@ -1316,8 +1331,21 @@ export class VaultTransport implements Transport {
1316
1331
  * thin wrapper over `writeInbound` so the inbound write path has ONE
1317
1332
  * implementation; only the default sender differs.
1318
1333
  */
1319
- async injectInbound(opts: { content: string; sender?: string }): Promise<{ id: string }> {
1320
- return this.writeInbound(opts.content, opts.sender ?? "runner");
1334
+ async injectInbound(opts: {
1335
+ content: string;
1336
+ sender?: string;
1337
+ /**
1338
+ * SUBJECT (roles×threads NOW slice) — the thread axis for a runner-fired turn.
1339
+ * When a job carries a subject, the runner threads it here; it's stamped onto the
1340
+ * inbound note's `metadata.subject` (via {@link writeInbound}'s `extraMeta`) so the
1341
+ * turn's composed prompt + (NEXT) thread routing can read it. Absent/empty → NO
1342
+ * `subject` field on the note (today's behavior exactly — the weave job is unaffected).
1343
+ */
1344
+ subject?: string;
1345
+ }): Promise<{ id: string }> {
1346
+ const subject = opts.subject?.trim();
1347
+ const extraMeta = subject ? { subject } : undefined;
1348
+ return this.writeInbound(opts.content, opts.sender ?? "runner", extraMeta);
1321
1349
  }
1322
1350
 
1323
1351
  /**
@@ -1576,6 +1604,10 @@ export class VaultTransport implements Transport {
1576
1604
  if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
1577
1605
  if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
1578
1606
  if (typeof m.lastStatus === "string") job.lastStatus = m.lastStatus;
1607
+ // roles×threads NOW slice: read the thread subject back (absent/blank → undefined).
1608
+ // Trim-guarded symmetrically with the write side (upsertJobNote) so a whitespace-only
1609
+ // value that somehow landed in the vault can't propagate downstream as a "subject".
1610
+ if (typeof m.subject === "string" && m.subject.trim()) job.subject = m.subject.trim();
1579
1611
  jobs.push(job);
1580
1612
  }
1581
1613
  return jobs;
@@ -1597,6 +1629,8 @@ export class VaultTransport implements Transport {
1597
1629
  createdAt: string;
1598
1630
  lastRunAt?: string;
1599
1631
  lastStatus?: string;
1632
+ /** The thread subject a fire carries (roles×threads NOW slice); absent → no field. */
1633
+ subject?: string;
1600
1634
  }): Promise<{ id: string }> {
1601
1635
  const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
1602
1636
  const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -1612,6 +1646,11 @@ export class VaultTransport implements Transport {
1612
1646
  if (job.tz) metadata.tz = job.tz;
1613
1647
  if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
1614
1648
  if (job.lastStatus) metadata.lastStatus = job.lastStatus;
1649
+ // roles×threads NOW slice: persist a non-empty subject; absent → no field (the weave
1650
+ // job writes none, so its note is byte-identical to HEAD).
1651
+ if (typeof job.subject === "string" && job.subject.trim().length > 0) {
1652
+ metadata.subject = job.subject.trim();
1653
+ }
1615
1654
 
1616
1655
  const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
1617
1656
  method: "POST",
@@ -1769,9 +1808,13 @@ export class VaultTransport implements Transport {
1769
1808
  }
1770
1809
  // Flatten the note's metadata into the inbound meta (string-valued), then
1771
1810
  // stamp our own provenance fields. `source`/`note_id`/`direction` are set
1772
- // explicitly so they win over anything in the note's metadata.
1811
+ // explicitly so they win over anything in the note's metadata. `subject` is
1812
+ // SKIPPED here and handled explicitly below (normalized to non-empty-or-absent),
1813
+ // so a blank `subject: " "` can't slip through the raw spread — absent and
1814
+ // whitespace-only must be indistinguishable downstream (the null-subject invariant).
1773
1815
  const flatMeta: Record<string, string> = {};
1774
1816
  for (const [k, v] of Object.entries(meta)) {
1817
+ if (k === "subject") continue;
1775
1818
  flatMeta[k] = typeof v === "string" ? v : String(v);
1776
1819
  }
1777
1820
 
@@ -1786,6 +1829,18 @@ export class VaultTransport implements Transport {
1786
1829
  attachments = await this.fetchInboundAttachments(note.id);
1787
1830
  }
1788
1831
 
1832
+ // SUBJECT (roles×threads NOW slice). The thread axis: when the inbound note
1833
+ // carries a non-empty string `metadata.subject`, surface it onto the emitted
1834
+ // event meta so it can flow through the queue → composed prompt → (NEXT) thread
1835
+ // routing. ABSENT/empty → no `subject` field on the emitted meta, so the emit is
1836
+ // BYTE-IDENTICAL to HEAD (the null-subject invariant — the weave path is untouched).
1837
+ // `subject` is deliberately SKIPPED in the `flatMeta` spread above and re-added here
1838
+ // ONLY when non-empty, so an empty/whitespace-only value can never leak through —
1839
+ // absent and blank are indistinguishable downstream.
1840
+ const rawSubject = meta.subject;
1841
+ const subject =
1842
+ typeof rawSubject === "string" && rawSubject.trim().length > 0 ? rawSubject : undefined;
1843
+
1789
1844
  this.ctx.emit({
1790
1845
  // `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
1791
1846
  // note metadata) — left as the channel name. The routing key rides in `meta.agent`.
@@ -1800,6 +1855,7 @@ export class VaultTransport implements Transport {
1800
1855
  note_id: note.id,
1801
1856
  sender: typeof meta.sender === "string" ? meta.sender : "",
1802
1857
  direction: "inbound",
1858
+ ...(subject ? { subject } : {}),
1803
1859
  },
1804
1860
  source: "vault",
1805
1861
  ...(attachments.length > 0 ? { attachments } : {}),