@openparachute/agent 0.2.3-rc.11 → 0.2.3-rc.13

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.11",
3
+ "version": "0.2.3-rc.13",
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",
@@ -442,13 +442,20 @@ export class ProgrammaticBackend implements AgentBackend {
442
442
  onInterim?: InterimSink,
443
443
  attachments?: InboundAttachment[],
444
444
  runContext?: RunContext,
445
+ subjectDossier?: string,
446
+ subject?: string,
445
447
  ): Promise<DeliverResult> {
446
448
  const spec = handle.spec;
447
449
  if (!spec) {
448
450
  return { ok: false, error: `ProgrammaticBackend.deliver: handle for "${handle.name}" carries no spec` };
449
451
  }
450
452
  const channel = handle.channel;
451
- const workspace = sessionWorkspace(this.deps.sessionsDir, spec.name);
453
+ // roles×threads NEXT slice (#120, G): the PER-THREAD private workspace. A subject keys
454
+ // a distinct `sessions/<name>--<slug(subject)>/` dir (its own `.mcp.json` /
455
+ // `system-prompt.txt` / HOME / attachment staging — see stageAttachments below, which
456
+ // takes THIS workspace), so concurrent subjects of one multi-threaded agent never clobber
457
+ // each other's per-turn files. NO subject → `sessions/<name>/` (HEAD, byte-identical).
458
+ const workspace = sessionWorkspace(this.deps.sessionsDir, spec.name, subject);
452
459
 
453
460
  // Resolve the Claude OAuth credential keyed on the wake channel. A missing
454
461
  // credential throws (CredentialNotConfigured) BEFORE any mint/spawn side effect.
@@ -587,10 +594,22 @@ export class ProgrammaticBackend implements AgentBackend {
587
594
  // Unset → no flag, no file (today's behavior unchanged). The `-file` form is
588
595
  // robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
589
596
  // lifecycle is tied to the workspace (like .mcp.json) — it disappears with it.
597
+ //
598
+ // COMPOSED PROMPT (roles×threads NOW slice): when a `subjectDossier` is handed in,
599
+ // the agent's stable ROLE (the spec's systemPrompt) is composed with the per-thread
600
+ // subject context as `roleBody + "\n\n---\n\n" + dossier`. The role stays constant
601
+ // across a role's threads; the subject-specific dossier layers on top. Absent/empty
602
+ // dossier → the role body is written VERBATIM (byte-identical to HEAD — the
603
+ // null-subject invariant; the run-context preamble stays on the MESSAGE, not here).
590
604
  let systemPromptFile: string | undefined;
591
605
  if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {
606
+ const roleBody = spec.systemPrompt;
607
+ const composed =
608
+ typeof subjectDossier === "string" && subjectDossier.length > 0
609
+ ? `${roleBody}\n\n---\n\n${subjectDossier}`
610
+ : roleBody;
592
611
  systemPromptFile = join(workspace, "system-prompt.txt");
593
- writeFileSync(systemPromptFile, spec.systemPrompt, { mode: 0o600 });
612
+ writeFileSync(systemPromptFile, composed, { mode: 0o600 });
594
613
  }
595
614
 
596
615
  // The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
@@ -43,7 +43,7 @@
43
43
  */
44
44
 
45
45
  import type { AgentSpec, AgentMode } from "../sandbox/types.ts";
46
- import { normalizeChannel } from "../sandbox/types.ts";
46
+ import { normalizeChannel, threadKey } from "../sandbox/types.ts";
47
47
  import type { AgentBackend, AgentHandle, InterimTurnEvent, RunContext, TurnSession } from "./types.ts";
48
48
  import type { InboundAttachment } from "../transport.ts";
49
49
 
@@ -118,6 +118,14 @@ export interface ThreadNote {
118
118
  * transport sanitizes it into the deterministic upsert path). Falls back to the channel.
119
119
  */
120
120
  name?: string;
121
+ /**
122
+ * The thread SUBJECT (roles×threads NEXT slice, #120) — when present, a MULTI-threaded
123
+ * thread becomes a DETERMINISTIC, upserting note at `threadKey(name, subject)`
124
+ * (`Threads/<ch>/<name>--<subject>`) carrying a session across fires (per-thread
125
+ * continuity), instead of a per-fire uuid note. Absent/empty → today's identity
126
+ * (single-threaded deterministic note / multi-threaded per-fire note), byte-identical to HEAD.
127
+ */
128
+ subject?: string;
121
129
  /** The `#agent/definition` note id (provenance; plain id string). */
122
130
  definition?: string;
123
131
  /** The mode the turn ran under — governs thread identity + whether the note upserts. */
@@ -308,26 +316,26 @@ function delay(ms: number): Promise<void> {
308
316
  /**
309
317
  * The thread id to stamp into an OUTBOUND note's `metadata.thread` (agent#163) — the
310
318
  * MODE-CORRECT, RESOLVABLE definition→thread→message link, mirroring the callback
311
- * `source_thread` fix (agent#124):
319
+ * `source_thread` fix (agent#124). Keyed on `perFire`:
312
320
  *
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-THREADEDthe 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
+ * - DETERMINISTIC thread (`perFire: false`) — single-threaded, OR a multi-threaded SUBJECT
322
+ * thread (roles×threads NEXT slice, #120). The note is the resolvable, deterministic
323
+ * `Threads/<safeChannel>/<name[--subject]>` note, so an observer reading the outbound's
324
+ * `metadata.thread` resolves the agent's stable thread with `query-notes { id }`. Stamp
325
+ * the resolvable `threadNoteId`. The pre-#163 bug stamped the per-turn correlation UUID
326
+ * here, which changed every turn and resolved to nothing.
327
+ * - PER-FIRE thread (`perFire: true`) a multi-threaded thread with NO subject: each fire is
328
+ * its own note at `Threads/<safeChannel>/<turnThreadId>`, so the per-fire id IS the leaf.
321
329
  *
322
330
  * Falls back to `turnThreadId` when no resolvable note id surfaced (no durable thread store
323
331
  * wired, or the thread-note write failed) — never undefined, so the link is always stamped.
324
332
  */
325
333
  export function outboundThreadId(
326
- multiThreaded: boolean,
334
+ perFire: boolean,
327
335
  turnThreadId: string,
328
336
  threadNoteId: string | undefined,
329
337
  ): string {
330
- if (multiThreaded) return turnThreadId;
338
+ if (perFire) return turnThreadId;
331
339
  return threadNoteId ?? turnThreadId;
332
340
  }
333
341
 
@@ -393,6 +401,16 @@ export interface QueuedMessage {
393
401
  * something else. Absent → the run context omits `fired-by`. Carries no routing meaning.
394
402
  */
395
403
  sender?: string;
404
+ /**
405
+ * The THREAD SUBJECT (roles×threads NOW slice) — rides from the inbound note's
406
+ * `metadata.subject`, through `contextFor.emit` (daemon.ts), onto this queue item.
407
+ * In the NOW slice it carries NO routing meaning — the drain stays per-CHANNEL serial,
408
+ * NOT per-subject — but it is threaded through so the composed prompt can fold in a
409
+ * subject dossier and the NEXT slice (#120 thread routing + per-thread session
410
+ * continuity) can key off it without re-plumbing. Absent → no subject (today's behavior;
411
+ * the weave path is untouched). Carries no routing meaning in NOW.
412
+ */
413
+ subject?: string;
396
414
  }
397
415
 
398
416
  /** A registered programmatic agent's live status (surfaced in /health + the list). */
@@ -427,9 +445,25 @@ export class ProgrammaticAgentRegistry {
427
445
  private readonly byChannel = new Map<string, ProgrammaticAgentHandle>();
428
446
  /** name → channel (the lifecycle index; an agent has exactly one wake channel). */
429
447
  private readonly nameToChannel = new Map<string, string>();
430
- /** channel → FIFO queue of pending messages. */
448
+ /**
449
+ * DRAIN-KEY → FIFO queue of pending messages — the PER-THREAD serial queue
450
+ * (roles×threads NEXT slice, #120). The drain key is {@link drainKeyFor}:
451
+ * - a single-threaded agent, OR a message with NO subject → the bare CHANNEL
452
+ * (byte-identical to HEAD — every current agent incl. the weave routes here).
453
+ * - a multi-threaded agent WITH a subject → `threadKey(spec.name, subject)`
454
+ * (`<name>--<subject>`), so distinct subjects of one agent get distinct queues.
455
+ * Keying queues + {@link draining} by the SAME drain key is what makes two messages
456
+ * of the SAME (name, subject) strictly serial while letting two DIFFERENT subjects
457
+ * of one agent run concurrently — the per-thread serial guarantee (class doc lines 13-20).
458
+ */
431
459
  private readonly queues = new Map<string, QueuedMessage[]>();
432
- /** channel → the in-flight drain promise (its presence == a worker is running). */
460
+ /**
461
+ * DRAIN-KEY → the in-flight drain promise (its presence == a worker is running for that
462
+ * thread). One promise per drain key is the structural lock: a second message for the
463
+ * SAME drain key never starts a second worker (it appends + the running worker picks it
464
+ * up), so a given (name, subject) thread is NEVER processed by two concurrent `claude -p`.
465
+ * A DIFFERENT subject is a DIFFERENT key → its own promise → may run concurrently.
466
+ */
433
467
  private readonly draining = new Map<string, Promise<void>>();
434
468
  /**
435
469
  * channel → FIFO queue of PENDING-INBOUND messages that arrived BEFORE a live
@@ -470,14 +504,28 @@ export class ProgrammaticAgentRegistry {
470
504
  * 2+ `--resume`s its prior conversation. Unwired (or no prior) → every turn creates a
471
505
  * fresh session. Multi-threaded NEVER consults it (each fire is a fresh thread).
472
506
  */
473
- private readonly readSession?: (channel: string, name: string) => Promise<string | undefined>;
507
+ private readonly readSession?: (
508
+ channel: string,
509
+ name: string,
510
+ subject?: string,
511
+ ) => Promise<string | undefined>;
474
512
  /**
475
513
  * Optional session CLEAR — wipe a single-threaded agent's persisted thread-note session
476
514
  * so its next turn starts a FRESH claude conversation (the per-agent restart). The daemon
477
515
  * wires this to the channel transport's `clearThreadSession`. Called by {@link resetSession}.
478
516
  * Unwired → reset is a clean no-op beyond returning that the agent exists.
479
517
  */
480
- private readonly clearSession?: (channel: string, name: string) => Promise<void>;
518
+ private readonly clearSession?: (channel: string, name: string, subject?: string) => Promise<void>;
519
+ /**
520
+ * Optional pre-turn SUBJECT-DOSSIER read (roles×threads NOW slice) — per-thread
521
+ * context for the current subject, folded into the composed system prompt (the
522
+ * programmatic backend's `roleBody + "\n\n---\n\n" + dossier`). Read in {@link drain}
523
+ * keyed on the inbound message's subject. UNWIRED in the NOW slice (the default registry
524
+ * does NOT set it — there's no dossier-note convention yet), so every turn composes the
525
+ * role body verbatim and the null-subject path is byte-identical to HEAD. The seam is
526
+ * here so a future dossier source threads in without re-plumbing `deliver`.
527
+ */
528
+ private readonly readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
481
529
  /** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
482
530
  private readonly outboundRetryBaseMs: number;
483
531
 
@@ -487,10 +535,22 @@ export class ProgrammaticAgentRegistry {
487
535
  writeThread?: WriteThread;
488
536
  writeCallback?: WriteCallback;
489
537
  onTurnEvent?: TurnEventSink;
490
- /** Read the persisted thread-note session UUID (single-threaded resume). */
491
- readSession?: (channel: string, name: string) => Promise<string | undefined>;
492
- /** Clear the persisted thread-note session (the per-agent restart / reset). */
493
- clearSession?: (channel: string, name: string) => Promise<void>;
538
+ /**
539
+ * Read the persisted thread-note session UUID. `subject` (roles×threads NEXT slice, #120)
540
+ * resolves the SUBJECT-scoped thread note for a multi-threaded subject thread; omitted
541
+ * the deterministic def-named note (single-threaded resume, HEAD).
542
+ */
543
+ readSession?: (channel: string, name: string, subject?: string) => Promise<string | undefined>;
544
+ /**
545
+ * Clear the persisted thread-note session (the per-agent restart / reset). `subject`
546
+ * resolves the subject-scoped thread note; omitted → the def-named note (HEAD).
547
+ */
548
+ clearSession?: (channel: string, name: string, subject?: string) => Promise<void>;
549
+ /**
550
+ * Read the per-thread subject dossier (roles×threads NOW slice). Optional + UNWIRED
551
+ * by default — the composed prompt is the role body verbatim until a dossier source exists.
552
+ */
553
+ readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
494
554
  /** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
495
555
  outboundRetryBaseMs?: number;
496
556
  }) {
@@ -501,6 +561,7 @@ export class ProgrammaticAgentRegistry {
501
561
  if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
502
562
  if (deps.readSession) this.readSession = deps.readSession;
503
563
  if (deps.clearSession) this.clearSession = deps.clearSession;
564
+ if (deps.readSubjectDossier) this.readSubjectDossier = deps.readSubjectDossier;
504
565
  this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
505
566
  }
506
567
 
@@ -526,6 +587,59 @@ export class ProgrammaticAgentRegistry {
526
587
  return normalizeChannel(spec.channels[0]!).name;
527
588
  }
528
589
 
590
+ /**
591
+ * The PER-THREAD serial DRAIN KEY for an inbound message (roles×threads NEXT slice, #120)
592
+ * — what {@link queues} + {@link draining} are keyed by:
593
+ * - SINGLE-threaded agent, OR a message with NO subject → the bare CHANNEL. This is the
594
+ * back-compat path: every agent today (incl. the weave) carries no subject, so the key
595
+ * is exactly the channel and the queue/drain behavior is byte-identical to HEAD.
596
+ * - MULTI-threaded agent WITH a subject → `threadKey(spec.name, subject)` (`<name>--<sub>`),
597
+ * so two subjects of one agent get DISTINCT queues + DISTINCT drain promises (concurrent),
598
+ * while two messages of the SAME subject share one queue + one promise (strictly serial).
599
+ *
600
+ * NOTE: a message can only carry a subject route when a LIVE handle exists for the channel
601
+ * (enqueue requires byChannel), so we read the mode off that handle. A missing handle (the
602
+ * caller already returns false) defaults to the channel key.
603
+ */
604
+ private drainKeyFor(channel: string, msg: QueuedMessage): string {
605
+ const handle = this.byChannel.get(channel);
606
+ if (!handle) return channel;
607
+ const multiThreaded = (handle.spec.mode ?? "single-threaded") === "multi-threaded";
608
+ if (!multiThreaded) return channel;
609
+ // threadKey returns the bare name for no/empty subject — but the channel (not the name)
610
+ // is the back-compat single-thread key, so only re-key when a subject actually narrows it.
611
+ const subject = msg.subject?.trim();
612
+ if (!subject) return channel;
613
+ return threadKey(handle.spec.name, subject);
614
+ }
615
+
616
+ /**
617
+ * Drop EVERY per-thread queue + drain promise belonging to a channel — used on
618
+ * teardown/re-key (deregister, a name moving channels). Because {@link queues} +
619
+ * {@link draining} are now keyed by the per-thread DRAIN KEY (`<channel>` or
620
+ * `<channel-name>--<subject>`), deleting only the bare-channel key would orphan a
621
+ * multi-threaded agent's subject queues. We clear the channel key AND every
622
+ * `<channel-name>--*` subject key whose threadKey is prefixed by the channel's agent name.
623
+ * An in-flight subject drain self-terminates on its next loop (it re-reads byChannel,
624
+ * now empty for that channel); dropping the `draining` entry just stops the map leaking.
625
+ */
626
+ private clearChannelQueues(channel: string): void {
627
+ // The bare-channel key (single-threaded / no-subject path).
628
+ this.queues.delete(channel);
629
+ this.draining.delete(channel);
630
+ // Subject keys are `${spec.name}--${slug(subject)}`. Resolve the agent name for this
631
+ // channel if a handle is still present; else fall back to the channel as the name prefix
632
+ // (covers the common case name===channel). Match any key with that `--`-prefixed form.
633
+ const handle = this.byChannel.get(channel);
634
+ const namePrefix = `${handle?.spec.name ?? channel}--`;
635
+ for (const key of [...this.queues.keys()]) {
636
+ if (key.startsWith(namePrefix)) this.queues.delete(key);
637
+ }
638
+ for (const key of [...this.draining.keys()]) {
639
+ if (key.startsWith(namePrefix)) this.draining.delete(key);
640
+ }
641
+ }
642
+
529
643
  /** Is a programmatic agent registered for this channel? (the inbound-routing check) */
530
644
  hasChannel(channel: string): boolean {
531
645
  return this.byChannel.has(channel);
@@ -558,6 +672,10 @@ export class ProgrammaticAgentRegistry {
558
672
  * /health + the agents list to render `programmatic · idle|working|queued:N`.
559
673
  */
560
674
  statusOf(channel: string): { state: ProgrammaticAgentState; queued: number } {
675
+ // NOTE (roles×threads NEXT slice): this reads only the BARE-channel drain key, so a
676
+ // multi-threaded agent whose active work is on subject keys (`name--subject`) reports
677
+ // `idle` here. Acceptable while multi-threaded agents aren't in production; tracked for
678
+ // a /health upgrade before they are (the drain key, not the channel, is the unit of work).
561
679
  const queued = this.queues.get(channel)?.length ?? 0;
562
680
  if (this.draining.has(channel)) {
563
681
  // A worker is in flight. If there are messages waiting BEHIND the in-flight
@@ -591,9 +709,13 @@ export class ProgrammaticAgentRegistry {
591
709
  // channel's EXPECTED mark + any stranded pending buffer — nothing routes there now,
592
710
  // so a residual mark/buffer would leak (reviewer nit; defense-in-depth — the normal
593
711
  // flow only ever expects the NEW channel before this register).
712
+ // Clear EVERY per-thread queue/drain for the old channel (bare + subject keys), not
713
+ // just the bare-channel key — a multi-threaded agent's subject queues would otherwise
714
+ // leak (roles×threads NEXT slice). clearChannelQueues reads the handle for the subject-key
715
+ // name prefix, so run it BEFORE byChannel.delete while the handle is still present — that
716
+ // resolves the REAL agent name even if name !== channel in a future split (reviewer nit).
717
+ this.clearChannelQueues(priorChannel);
594
718
  this.byChannel.delete(priorChannel);
595
- this.queues.delete(priorChannel);
596
- this.draining.delete(priorChannel);
597
719
  this.expectedChannels.delete(priorChannel);
598
720
  this.pending.delete(priorChannel);
599
721
  }
@@ -714,9 +836,13 @@ export class ProgrammaticAgentRegistry {
714
836
  const channel = this.nameToChannel.get(name);
715
837
  if (channel === undefined) return false;
716
838
  const handle = this.byChannel.get(channel);
839
+ // Clear every per-thread queue/drain for this channel (bare + subject keys) BEFORE
840
+ // dropping byChannel, so the handle is still present to resolve the agent-name prefix
841
+ // for the subject keys (roles×threads NEXT slice). An in-flight subject drain
842
+ // self-terminates on its next loop (it re-reads byChannel, now empty for the channel).
843
+ this.clearChannelQueues(channel);
717
844
  this.byChannel.delete(channel);
718
845
  this.nameToChannel.delete(name);
719
- this.queues.delete(channel);
720
846
  // Clear the EXPECTED mark + any buffered pending inbound for this channel too —
721
847
  // the agent is gone, so a pending message has nothing to drain into and would
722
848
  // strand forever (and the next register would replay stale messages). The daemon's
@@ -778,17 +904,26 @@ export class ProgrammaticAgentRegistry {
778
904
  */
779
905
  enqueue(channel: string, msg: QueuedMessage): boolean {
780
906
  if (!this.byChannel.has(channel)) return false;
781
- const queue = this.queues.get(channel) ?? [];
907
+ // PER-THREAD ROUTING (roles×threads NEXT slice, #120): resolve the drain key. A
908
+ // single-threaded / no-subject message keys by the bare CHANNEL (byte-identical to
909
+ // HEAD); a multi-threaded agent WITH a subject keys by `threadKey(name, subject)`, so
910
+ // distinct subjects get distinct queues + distinct drain promises (concurrent), while
911
+ // the SAME subject shares one queue + one promise (strictly serial). The serial
912
+ // guarantee is per DRAIN KEY, not per channel.
913
+ const drainKey = this.drainKeyFor(channel, msg);
914
+ const queue = this.queues.get(drainKey) ?? [];
782
915
  queue.push(msg);
783
- this.queues.set(channel, queue);
784
- // Start the worker if it isn't already running. The drain promise's PRESENCE in
785
- // `draining` is the "a worker is running" flag — set it synchronously before any
786
- // await so a second enqueue in the same tick can't start a second worker.
787
- if (!this.draining.has(channel)) {
788
- const p = this.drain(channel).finally(() => {
789
- this.draining.delete(channel);
916
+ this.queues.set(drainKey, queue);
917
+ // Start the worker if it isn't already running FOR THIS DRAIN KEY. The drain promise's
918
+ // PRESENCE in `draining` (keyed by drain key) is the "a worker is running for this
919
+ // thread" flag — set it synchronously before any await so a second enqueue for the same
920
+ // drain key in the same tick can't start a second worker (the per-thread serial lock).
921
+ // A DIFFERENT drain key has its own entry → its own worker → may run concurrently.
922
+ if (!this.draining.has(drainKey)) {
923
+ const p = this.drain(drainKey, channel).finally(() => {
924
+ this.draining.delete(drainKey);
790
925
  });
791
- this.draining.set(channel, p);
926
+ this.draining.set(drainKey, p);
792
927
  }
793
928
  return true;
794
929
  }
@@ -807,9 +942,13 @@ export class ProgrammaticAgentRegistry {
807
942
  * failure is logged; the turn still counts as drained (the reply is durable-or-not
808
943
  * at the transport's discretion; we don't re-run the turn, which would fork).
809
944
  */
810
- private async drain(channel: string): Promise<void> {
945
+ private async drain(drainKey: string, channel: string): Promise<void> {
811
946
  for (;;) {
812
- const queue = this.queues.get(channel);
947
+ // The FIFO for THIS thread — keyed by the per-thread drain key, NOT the channel. A
948
+ // single-threaded / no-subject thread's drain key IS the channel (back-compat); a
949
+ // subject thread's is `threadKey(name, subject)`. Reading the queue by drain key is
950
+ // what keeps two subjects of one agent independently serial.
951
+ const queue = this.queues.get(drainKey);
813
952
  if (!queue || queue.length === 0) return;
814
953
  const handle = this.byChannel.get(channel);
815
954
  if (!handle) return; // deregistered mid-drain — stop.
@@ -834,9 +973,23 @@ export class ProgrammaticAgentRegistry {
834
973
  // CREATE a fresh session with a new uuid (`--session-id`). The backend just runs the
835
974
  // turn with this {@link TurnSession}; it reads no session store.
836
975
  const multiThreaded = (handle.spec.mode ?? "single-threaded") === "multi-threaded";
976
+ // The thread SUBJECT (roles×threads NEXT slice, #120) — trimmed; empty → none.
977
+ const turnSubject = msg.subject?.trim() ? msg.subject : undefined;
978
+ // A multi-threaded agent WITH a subject is a NAMED thread: a deterministic note at
979
+ // `Threads/<ch>/<name>--<subject>` that upserts + carries a session across fires
980
+ // (turning "fresh thread per fire" into "resume the named thread"). A multi-threaded
981
+ // agent with NO subject stays fresh-per-fire (HEAD); a single-threaded agent is the
982
+ // HEAD deterministic def-named note.
983
+ const hasSubjectThread = multiThreaded && !!turnSubject;
984
+ // RESUME the persisted session when one exists for this thread note:
985
+ // - single-threaded → its deterministic def-named note (HEAD behavior, unchanged).
986
+ // - multi-threaded WITH a subject → the subject-scoped note (NEW: per-thread continuity).
987
+ // - multi-threaded with NO subject → NEVER resume (each fire is a fresh thread; HEAD).
988
+ // The leaf is resolved transport-side via `singleThreadedPath(channel, name, subject)`,
989
+ // so write + read + clear all agree on the path (no leaf math duplicated here).
837
990
  let resumeId: string | undefined;
838
- if (!multiThreaded && this.readSession) {
839
- resumeId = await this.readSession(handle.channel, handle.spec.name);
991
+ if (this.readSession && (!multiThreaded || hasSubjectThread)) {
992
+ resumeId = await this.readSession(handle.channel, handle.spec.name, turnSubject);
840
993
  }
841
994
  const turnSession: TurnSession = resumeId
842
995
  ? { id: resumeId, resume: true }
@@ -871,10 +1024,13 @@ export class ProgrammaticAgentRegistry {
871
1024
  // single-threaded resume turn the prior session is preserved by writeThread anyway.
872
1025
  });
873
1026
  // 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);
1027
+ // (agent#163): a DETERMINISTIC thread (single-threaded OR a multi-threaded subject
1028
+ // thread) → the deterministic thread-NOTE id (stable across turns); a PER-FIRE thread
1029
+ // (multi-threaded, NO subject) the per-fire `turnThreadId`. `perFire` is multi-threaded
1030
+ // AND no subject. Computed once from the start-ensure id so every path (early failure, ok,
1031
+ // outbound-failure) stamps the same resolvable link.
1032
+ const perFire = multiThreaded && !hasSubjectThread;
1033
+ const outThreadId = outboundThreadId(perFire, turnThreadId, startThreadNoteId);
878
1034
 
879
1035
  // RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
880
1036
  // know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
@@ -891,6 +1047,24 @@ export class ProgrammaticAgentRegistry {
891
1047
  ...(firedBy ? { firedBy } : {}),
892
1048
  };
893
1049
 
1050
+ // SUBJECT DOSSIER (roles×threads NOW slice): when the inbound carries a subject AND a
1051
+ // dossier source is wired, resolve the per-thread context the backend folds into the
1052
+ // composed system prompt. UNWIRED by default (no dossier-note convention yet) and
1053
+ // no-subject always → `undefined`, so the system prompt is the role body verbatim and
1054
+ // the null-subject path is byte-identical to HEAD. Best-effort: a dossier read failure
1055
+ // logs + the turn still runs (role-only), never strands the queue.
1056
+ let subjectDossier: string | undefined;
1057
+ if (msg.subject && this.readSubjectDossier) {
1058
+ try {
1059
+ subjectDossier = await this.readSubjectDossier(handle.channel, msg.subject);
1060
+ } catch (err) {
1061
+ console.error(
1062
+ `parachute-agent: subject-dossier read for channel "${channel}" ` +
1063
+ `subject "${msg.subject}" failed (turn runs role-only): ${(err as Error).message}`,
1064
+ );
1065
+ }
1066
+ }
1067
+
894
1068
  let result;
895
1069
  try {
896
1070
  // Forward each interim event to the streaming-view sink (keyed by channel)
@@ -906,6 +1080,14 @@ export class ProgrammaticAgentRegistry {
906
1080
  msg.attachments,
907
1081
  // agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
908
1082
  runContext,
1083
+ // roles×threads NOW slice: the per-thread subject dossier, folded into the composed
1084
+ // system prompt. Undefined (no subject / unwired source) → role body verbatim.
1085
+ subjectDossier,
1086
+ // roles×threads NEXT slice (#120, G): the thread SUBJECT — the backend keys the
1087
+ // PER-THREAD private workspace off it (`sessions/<name>--<subject>/`), so concurrent
1088
+ // subjects of one agent never clobber each other's per-turn `.mcp.json` /
1089
+ // `system-prompt.txt` / HOME. Undefined → `sessions/<name>/` (HEAD, unchanged).
1090
+ turnSubject,
909
1091
  );
910
1092
  } catch (err) {
911
1093
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -1198,6 +1380,12 @@ export class ProgrammaticAgentRegistry {
1198
1380
  const thread: ThreadNote = {
1199
1381
  channel: handle.channel,
1200
1382
  name: handle.spec.name,
1383
+ // roles×threads NEXT slice (#120): carry the thread SUBJECT so the transport can
1384
+ // resolve a subject-scoped deterministic thread note (`Threads/<ch>/<name>--<subject>`)
1385
+ // that UPSERTS + carries a session across fires (per-thread continuity) instead of a
1386
+ // per-fire uuid note. Absent/empty → no subject → today's identity (single-threaded
1387
+ // deterministic note, or multi-threaded per-fire note) is byte-identical to HEAD.
1388
+ ...(msg.subject?.trim() ? { subject: msg.subject } : {}),
1201
1389
  ...(handle.spec.definition ? { definition: handle.spec.definition } : {}),
1202
1390
  mode: handle.spec.mode ?? "single-threaded",
1203
1391
  status,
@@ -244,6 +244,22 @@ export interface AgentBackend {
244
244
  * The programmatic backend prepends it as a concise, clearly-labeled preamble to the turn
245
245
  * message so the agent stamps ACCURATE times instead of fabricating them. ADDITIVE — omitted
246
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).
255
+ *
256
+ * `subject` (optional, roles×threads NEXT slice #120) is the thread SUBJECT — the programmatic
257
+ * backend keys the agent's PER-THREAD private session workspace off it
258
+ * (`sessions/<name>--<slug(subject)>/`), so concurrent subjects of one multi-threaded agent get
259
+ * ISOLATED per-turn files (`.mcp.json`, `system-prompt.txt`, HOME, attachment staging) and never
260
+ * clobber each other. ADDITIVE — omitted/empty → `sessions/<name>/` (byte-identical to HEAD;
261
+ * the null-subject invariant). Distinct from `subjectDossier` (which is prompt CONTENT); this is
262
+ * the workspace IDENTITY.
247
263
  */
248
264
  deliver(
249
265
  handle: AgentHandle,
@@ -252,6 +268,8 @@ export interface AgentBackend {
252
268
  onInterim?: InterimSink,
253
269
  attachments?: InboundAttachment[],
254
270
  runContext?: RunContext,
271
+ subjectDossier?: string,
272
+ subject?: string,
255
273
  ): Promise<DeliverResult>;
256
274
 
257
275
  /**
package/src/daemon.ts CHANGED
@@ -345,6 +345,10 @@ export function contextFor(
345
345
  // agent#162: carry the inbound sender so the drain can derive the run-context
346
346
  // `fired-by` (a scheduled `runner:<jobId>` fire vs an interactive/delegated message).
347
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 } : {}),
348
352
  });
349
353
  return;
350
354
  }
@@ -372,6 +376,10 @@ export function contextFor(
372
376
  // agent#162: carry the sender through the pending buffer too, so a turn that runs on
373
377
  // register() still derives the right run-context `fired-by`.
374
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 } : {}),
375
383
  });
376
384
  if (outcome === "queued") return;
377
385
  // outcome === "unknown" — not an expected programmatic channel. It may still be a
@@ -830,11 +838,14 @@ export function buildWriteCallback(channels: Map<string, Channel>): WriteCallbac
830
838
  */
831
839
  export function buildReadSession(
832
840
  channels: Map<string, Channel>,
833
- ): (channel: string, name: string) => Promise<string | undefined> {
834
- return async (channel, name) => {
841
+ ): (channel: string, name: string, subject?: string) => Promise<string | undefined> {
842
+ return async (channel, name, subject) => {
835
843
  const ch = channels.get(channel);
836
844
  if (!ch?.transport.readThreadSession) return undefined;
837
- return ch.transport.readThreadSession(channel, name);
845
+ // roles×threads NEXT slice (#120): pass the subject so a multi-threaded SUBJECT thread
846
+ // resumes its own subject-scoped note (`Threads/<ch>/<name>--<subject>`). Omitted → the
847
+ // deterministic def-named note (single-threaded resume, HEAD).
848
+ return ch.transport.readThreadSession(channel, name, subject);
838
849
  };
839
850
  }
840
851
 
@@ -848,11 +859,12 @@ export function buildReadSession(
848
859
  */
849
860
  export function buildClearSession(
850
861
  channels: Map<string, Channel>,
851
- ): (channel: string, name: string) => Promise<void> {
852
- return async (channel, name) => {
862
+ ): (channel: string, name: string, subject?: string) => Promise<void> {
863
+ return async (channel, name, subject) => {
853
864
  const ch = channels.get(channel);
854
865
  if (!ch?.transport.clearThreadSession) return;
855
- await ch.transport.clearThreadSession(channel, name);
866
+ // roles×threads NEXT slice (#120): a subject clears its own subject-scoped note.
867
+ await ch.transport.clearThreadSession(channel, name, subject);
856
868
  };
857
869
  }
858
870
 
@@ -1068,6 +1080,15 @@ export async function reregisterProgrammaticAgents(
1068
1080
  }
1069
1081
  let count = 0;
1070
1082
  for (const name of entries) {
1083
+ // PER-THREAD WORKSPACE GUARD (roles×threads NEXT slice #120, G). A subject thread's
1084
+ // workspace dir is `<name>--<slug(subject)>` (the `--` separator). Those are PER-TURN
1085
+ // scratch dirs for a multi-threaded agent's subject threads, NOT agent specs — they hold
1086
+ // no authoritative spec.json (the spec is per-AGENT, persisted only at the name-only
1087
+ // `<name>/` dir). Re-registering one would resurrect a phantom agent keyed on a
1088
+ // subject-leaf name. So a dir name containing `--` is INERT on boot: skip it. The
1089
+ // canonical per-agent spec dir (no `--`) is re-registered as before — byte-identical to
1090
+ // HEAD for every current agent (no current agent's name contains `--`).
1091
+ if (name.includes("--")) continue;
1071
1092
  const workspace = sessionWorkspace(sessionsDirPath, name);
1072
1093
  const spec = readPersistedSpec(workspace);
1073
1094
  // Re-register ONLY specs that explicitly persisted `backend: "programmatic"`.
@@ -1815,7 +1836,12 @@ export function createFetchHandler(
1815
1836
  if (!transport) {
1816
1837
  return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
1817
1838
  }
1818
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
1839
+ // roles×threads NOW slice: thread the job's subject (absent → no subject).
1840
+ await transport.injectInbound({
1841
+ content: job.message,
1842
+ sender: `runner:${job.id}`,
1843
+ ...(job.subject ? { subject: job.subject } : {}),
1844
+ });
1819
1845
  return json({ ok: true, id, status: "ok" });
1820
1846
  } catch (err) {
1821
1847
  return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
@@ -3381,7 +3407,14 @@ function main(): void {
3381
3407
  if (!transport) {
3382
3408
  throw new Error(`channel "${job.channel}" is not a live vault channel`);
3383
3409
  }
3384
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
3410
+ // roles×threads NOW slice: thread the job's subject onto the inbound note so the
3411
+ // turn's composed prompt can fold in its dossier. Absent → no subject (the weave
3412
+ // job carries none → byte-identical to HEAD).
3413
+ await transport.injectInbound({
3414
+ content: job.message,
3415
+ sender: `runner:${job.id}`,
3416
+ ...(job.subject ? { subject: job.subject } : {}),
3417
+ });
3385
3418
  },
3386
3419
  // Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
3387
3420
  // 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
+ }
@@ -27,6 +27,7 @@ import { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync } from "n
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
  import type { AgentSpec, BaseBinds } from "./sandbox/types.ts";
30
+ import { threadKey } from "./sandbox/types.ts";
30
31
  import { Sandbox, type SandboxEngine, type WrappedCommand } from "./sandbox/index.ts";
31
32
  import type { EgressBaseInput } from "./sandbox/egress.ts";
32
33
  import { DENYLISTED_ENV } from "./credentials.ts";
@@ -181,9 +182,18 @@ export interface SpawnAgentBaseDeps {
181
182
  ripgrep?: { command: string; args?: string[] };
182
183
  }
183
184
 
184
- /** Per-session workspace dir under the sessions base. */
185
- export function sessionWorkspace(sessionsDir: string, specName: string): string {
186
- return join(sessionsDir, specName);
185
+ /**
186
+ * Per-session workspace dir under the sessions base. The leaf is {@link threadKey}`(specName,
187
+ * subject)` (roles×threads NEXT slice, #120):
188
+ * - NO subject → `<sessionsDir>/<specName>` — byte-identical to HEAD (every current agent,
189
+ * incl. the weave, and the per-AGENT spec.json / persist callsites which never pass a subject).
190
+ * - A subject → `<sessionsDir>/<specName>--<slug(subject)>` — a PER-THREAD private workspace
191
+ * (its own `.mcp.json` / `system-prompt.txt` / HOME / staging), so concurrent subjects of one
192
+ * multi-threaded agent never clobber each other's per-turn files. `slug` (inside threadKey)
193
+ * strips the untrusted subject to a path-safe leaf — no traversal.
194
+ */
195
+ export function sessionWorkspace(sessionsDir: string, specName: string, subject?: string): string {
196
+ return join(sessionsDir, threadKey(specName, subject));
187
197
  }
188
198
 
189
199
  /**
package/src/transport.ts CHANGED
@@ -80,6 +80,15 @@ export interface ThreadRecord {
80
80
  * Omitted falls back to the channel (the 1:1 default, where channel == name).
81
81
  */
82
82
  name?: string;
83
+ /**
84
+ * The thread SUBJECT (roles×threads NEXT slice, #120). When present on a MULTI-threaded
85
+ * thread, the note becomes a DETERMINISTIC, upserting record at `threadKey(name, subject)`
86
+ * (`Threads/<safeChannel>/<safeName>--<safeSubject>`) — rolling turn_count + cumulative
87
+ * usage + a preserved session across fires (per-thread continuity), exactly like the
88
+ * single-threaded deterministic path but at the subject-scoped leaf. Absent/empty → the
89
+ * HEAD identity (single-threaded deterministic note / multi-threaded per-fire uuid note).
90
+ */
91
+ subject?: string;
83
92
  /** The `#agent/definition` note id this thread came from (provenance; plain id string). */
84
93
  definition?: string;
85
94
  /** The mode the turn ran under — governs thread identity + whether the note upserts. */
@@ -235,20 +244,23 @@ export interface Transport {
235
244
  */
236
245
  writeThread?(thread: ThreadRecord): Promise<{ sent: string[] }>;
237
246
  /**
238
- * Optional: read the persisted Claude session UUID for a single-threaded agent's
239
- * deterministic `#agent/thread` note (the thread≡session record), or undefined when
240
- * none yet (the first turn). The daemon reads this BEFORE a turn so it can `--resume`
241
- * the prior conversation. Only a durable transport (the VaultTransport) implements it;
242
- * transports without a durable thread store (telegram) omit it.
247
+ * Optional: read the persisted Claude session UUID for a thread's deterministic
248
+ * `#agent/thread` note (the thread≡session record), or undefined when none yet (the
249
+ * first turn). The daemon reads this BEFORE a turn so it can `--resume` the prior
250
+ * conversation. `subject` (roles×threads NEXT slice, #120) resolves the SUBJECT-scoped
251
+ * note (`Threads/<ch>/<name>--<subject>`) for a multi-threaded subject thread; omitted
252
+ * the def-named note (single-threaded resume, HEAD). Only a durable transport (the
253
+ * VaultTransport) implements it; transports without a durable thread store (telegram) omit it.
243
254
  */
244
- readThreadSession?(channel: string, name: string): Promise<string | undefined>;
255
+ readThreadSession?(channel: string, name: string, subject?: string): Promise<string | undefined>;
245
256
  /**
246
- * Optional: CLEAR the persisted session on a single-threaded agent's `#agent/thread`
247
- * note so its next turn starts a fresh Claude conversation (the per-agent restart /
248
- * reset). Only a durable transport (the VaultTransport) implements it; transports
249
- * without a durable thread store (telegram) omit it.
257
+ * Optional: CLEAR the persisted session on a thread's `#agent/thread` note so its next
258
+ * turn starts a fresh Claude conversation (the per-agent restart / reset). `subject`
259
+ * resolves the subject-scoped note; omitted the def-named note (HEAD). Only a durable
260
+ * transport (the VaultTransport) implements it; transports without a durable thread store
261
+ * (telegram) omit it.
250
262
  */
251
- clearThreadSession?(channel: string, name: string): Promise<void>;
263
+ clearThreadSession?(channel: string, name: string, subject?: string): Promise<void>;
252
264
  /**
253
265
  * Optional: write an agent-to-agent CALLBACK as an INBOUND note on THIS channel (the
254
266
  * "reply_to" substrate). A recipient agent's drain, on turn completion, calls this on the
@@ -66,6 +66,10 @@ import type {
66
66
  CallbackMetadata,
67
67
  InboundAttachment,
68
68
  } from "../transport.ts";
69
+ // roles×threads NEXT slice (#120): the shared thread-key sanitizer — REUSED here for the
70
+ // subject-scoped deterministic thread-note leaf (`<name>--<slug(subject)>`) so the path
71
+ // math matches the registry's drain key exactly (no drift).
72
+ import { threadKey } from "../sandbox/types.ts";
69
73
 
70
74
  /** The safe basename of a (possibly path-ful, possibly untrusted) string — the LAST
71
75
  * path segment, with traversal markers stripped. Used to derive a display `filename`
@@ -161,6 +165,11 @@ export interface JobNote {
161
165
  lastRunAt?: string;
162
166
  /** "ok" / "error: …" from the most recent fire. */
163
167
  lastStatus?: string;
168
+ /**
169
+ * The THREAD SUBJECT a fire carries (roles×threads NOW slice) — read back from
170
+ * `metadata.subject`. Optional; absent → today's behavior (no subject).
171
+ */
172
+ subject?: string;
164
173
  }
165
174
 
166
175
  /** The metadata payload written for a job note (all string-typed, per the vault). */
@@ -176,6 +185,13 @@ export interface JobNoteMetadata {
176
185
  createdAt: string;
177
186
  lastRunAt?: string;
178
187
  lastStatus?: string;
188
+ /**
189
+ * The THREAD SUBJECT a fire of this job carries (roles×threads NOW slice) — stamped
190
+ * onto the inbound note the runner injects, so the turn's composed prompt + (NEXT)
191
+ * thread routing can read it. Optional; absent → today's behavior (the weave job
192
+ * carries none).
193
+ */
194
+ subject?: string;
179
195
  }
180
196
 
181
197
  /**
@@ -808,20 +824,33 @@ export class VaultTransport implements Transport {
808
824
  }
809
825
 
810
826
  /**
811
- * The DETERMINISTIC path of a single-threaded agent's ONE thread note
812
- * `Threads/<safeChannel>/<safeName>` (named after the def). The single shared
813
- * source of truth for that path so {@link writeThread} (the upsert) and
814
- * {@link readThreadSession} (the pre-turn session read) can never disagree on
815
- * where the note lives. Sanitizes both segments to a flat, predictable slug.
827
+ * The DETERMINISTIC path of a thread note that UPSERTS in place
828
+ * `Threads/<safeChannel>/<leaf>`. The single shared source of truth for that path so
829
+ * {@link writeThread} (the upsert), {@link readThreadSession} (the pre-turn session read),
830
+ * and {@link clearThreadSession} (the reset) can never disagree on where the note lives.
831
+ * Sanitizes the channel segment to a flat, predictable slug.
832
+ *
833
+ * The LEAF is {@link threadKey}`(name, subject)` (roles×threads NEXT slice, #120):
834
+ * - NO subject → the bare def name (`<safeName>`) — the HEAD single-threaded path,
835
+ * byte-identical. `threadKey` returns the bare name AND `slug`s it the same way the
836
+ * prior inline `replace(/[^a-zA-Z0-9_-]/g, "-")` did, so the path is unchanged.
837
+ * - A subject → `<safeName>--<safeSubject>` (the subject thread's deterministic note),
838
+ * so a multi-threaded SUBJECT upserts + carries a session across fires.
816
839
  *
817
- * COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
818
- * on the same channel would share this note. Acceptable because the registry enforces
819
- * ONE agent per channel (byChannel index), so the collision can't arise in practice.
840
+ * COLLISION NOTE: two agents whose (name, subject) collapse to the SAME leaf on the same
841
+ * channel would share this note. Acceptable: the registry enforces ONE agent per channel
842
+ * (byChannel index), and distinct subjects produce distinct leaves.
820
843
  */
821
- private singleThreadedPath(channel: string, name: string): string {
844
+ private singleThreadedPath(channel: string, name: string, subject?: string): string {
822
845
  const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
846
+ // Slug the NAME segment exactly as HEAD did (the prior inline `replace`), then let
847
+ // threadKey append `--<slug(subject)>` when a subject narrows the thread. With no
848
+ // subject threadKey returns the (already-slugged) name verbatim → the leaf is
849
+ // byte-identical to the HEAD single-threaded path. With a subject the leaf becomes
850
+ // `<safeName>--<safeSubject>` (both slugged → path-safe, no traversal).
823
851
  const safeName = (name ?? channel).replace(/[^a-zA-Z0-9_-]/g, "-");
824
- return `${THREAD_PATH_PREFIX}/${safeChannel}/${safeName}`;
852
+ const leaf = threadKey(safeName, subject);
853
+ return `${THREAD_PATH_PREFIX}/${safeChannel}/${leaf}`;
825
854
  }
826
855
 
827
856
  /**
@@ -853,32 +882,36 @@ export class VaultTransport implements Transport {
853
882
  async writeThread(thread: ThreadRecord): Promise<{ sent: string[] }> {
854
883
  const safeChannel = thread.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
855
884
  const singleThreaded = thread.mode === "single-threaded";
856
-
857
- // IDENTITY by mode (HARD CONSTRAINT 3 the path leaf IS the thread's identity; no
858
- // ambiguous `thread_id` metadata field). single-threaded: a DETERMINISTIC leaf named
859
- // after the def (the agent/spec name, sanitized) so the SAME note upserts across turns.
860
- // multi-threaded: a fresh uuid per fire (one fire = one thread = one note today).
861
- // COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
862
- // on the same channel would upsert each other's thread note. Acceptable because the
863
- // registry enforces ONE agent per channel (byChannel index), so the collision can't
864
- // arise in practice.
865
- // Multi-threaded leaf: a per-FIRE id. Reuse the caller's `threadId` when given (a
866
- // re-record of the same turn e.g. the outbound-failure status flip targets the
867
- // SAME per-fire note instead of minting a duplicate); else mint a fresh one. Single-
868
- // threaded uses the DETERMINISTIC path (named after the def) so the one-per-channel
869
- // note upserts computed via {@link singleThreadedPath} so writeThread and
870
- // readThreadSession agree on exactly where the note lives.
871
- const path = singleThreaded
872
- ? this.singleThreadedPath(thread.channel, thread.name ?? thread.channel)
885
+ // roles×threads NEXT slice (#120): a thread is DETERMINISTIC (a named, upserting note
886
+ // that rolls turn_count/usage + carries a session across fires) when it's single-threaded
887
+ // (HEAD) OR it's a multi-threaded thread that carries a SUBJECT. A subject-scoped
888
+ // multi-threaded thread turns "fresh note per fire" into "resume the named thread" its
889
+ // leaf is `<name>--<subject>`. A multi-threaded thread with NO subject stays a per-fire
890
+ // uuid note (HEAD), byte-identical for the weave + every current agent.
891
+ const subject = thread.subject?.trim();
892
+ const deterministic = singleThreaded || (!singleThreaded && !!subject);
893
+
894
+ // IDENTITY (HARD CONSTRAINT 3 the path leaf IS the thread's identity; no ambiguous
895
+ // `thread_id` metadata field). A DETERMINISTIC thread upserts at a stable leaf named after
896
+ // the def (+ subject when present), via {@link singleThreadedPath} so writeThread,
897
+ // readThreadSession + clearThreadSession all agree on exactly where the note lives. A
898
+ // non-deterministic (multi-threaded, no-subject) thread is a per-FIRE uuid note — reuse
899
+ // the caller's `threadId` when given (a re-record of the same turn — the outbound-failure
900
+ // status flip — targets the SAME per-fire note instead of minting a duplicate); else mint.
901
+ // COLLISION NOTE: two threads whose (name[, subject]) collapse to the SAME leaf on the
902
+ // same channel would upsert each other's note. Acceptable: the registry enforces ONE agent
903
+ // per channel (byChannel index), and distinct subjects produce distinct leaves.
904
+ const path = deterministic
905
+ ? this.singleThreadedPath(thread.channel, thread.name ?? thread.channel, subject)
873
906
  : `${THREAD_PATH_PREFIX}/${safeChannel}/${thread.threadId ?? crypto.randomUUID()}`;
874
907
 
875
- // For single-threaded UPSERT, read the existing thread note (by its deterministic
876
- // path) to roll up the aggregates. SAFE because the drain is serial per channel and
877
- // single-threaded is one-thread-per-channel today (see the method doc) — there's no
878
- // concurrent writer to lose an update against.
879
- // WHEN CONTINUATION brings concurrent threads per channel, switch to re-deriving
880
- // aggregates from the #agent/message children or a vault atomic-merge, to avoid
881
- // lost-update.
908
+ // For a DETERMINISTIC UPSERT (single-threaded OR a multi-threaded subject thread), read
909
+ // the existing thread note (by its deterministic path) to roll up the aggregates. SAFE
910
+ // because the drain is serial PER THREAD KEY (registry per-thread serial guarantee) — a
911
+ // given (name, subject) thread has no concurrent writer to lose an update against.
912
+ // WHEN per-channel concurrency across DIFFERENT subjects lands they each have their OWN
913
+ // deterministic note (distinct leaf), so no cross-subject lost-update; only same-subject
914
+ // would race, and that's serialized by the per-thread drain.
882
915
  let priorTurnCount = 0;
883
916
  let priorInputTokens = 0;
884
917
  let priorOutputTokens = 0;
@@ -886,7 +919,7 @@ export class VaultTransport implements Transport {
886
919
  let priorStartedAt: string | undefined;
887
920
  let priorLastTurnAt: string | undefined;
888
921
  let priorSession: string | undefined;
889
- if (singleThreaded) {
922
+ if (deterministic) {
890
923
  const prior = await this.readThreadNote(path);
891
924
  if (prior) {
892
925
  priorTurnCount = numFromMeta(prior.metadata?.turn_count);
@@ -920,8 +953,8 @@ export class VaultTransport implements Transport {
920
953
  const isStart = thread.phase === "start";
921
954
  let turnCount: number;
922
955
  if (isStart) {
923
- turnCount = singleThreaded ? priorTurnCount : 0;
924
- } else if (singleThreaded) {
956
+ turnCount = deterministic ? priorTurnCount : 0;
957
+ } else if (deterministic) {
925
958
  turnCount = thread.sameTurn ? priorTurnCount : priorTurnCount + 1;
926
959
  } else {
927
960
  turnCount = 1;
@@ -932,13 +965,13 @@ export class VaultTransport implements Transport {
932
965
  const startedAt = priorStartedAt ?? thread.started_at;
933
966
  const lastTurnAt = isStart ? (priorLastTurnAt ?? "") : thread.ended_at;
934
967
 
935
- // Cumulative usage: single-threaded SUMS this turn into the prior totals; multi-threaded
936
- // carries just this turn's (one fire = one thread).
968
+ // Cumulative usage: a DETERMINISTIC thread SUMS this turn into the prior totals; a
969
+ // per-fire (multi-threaded, no-subject) note carries just this turn's (one fire = one thread).
937
970
  const inputTokens =
938
- (singleThreaded ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
971
+ (deterministic ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
939
972
  const outputTokens =
940
- (singleThreaded ? priorOutputTokens : 0) + (thread.usage?.outputTokens ?? 0);
941
- const costUsd = (singleThreaded ? priorCostUsd : 0) + (thread.usage?.totalCostUsd ?? 0);
973
+ (deterministic ? priorOutputTokens : 0) + (thread.usage?.outputTokens ?? 0);
974
+ const costUsd = (deterministic ? priorCostUsd : 0) + (thread.usage?.totalCostUsd ?? 0);
942
975
 
943
976
  // Indexed string fields (queryable) + the thread-state observability fields. The
944
977
  // vault stores metadata as strings; numbers are stringified.
@@ -957,14 +990,14 @@ export class VaultTransport implements Transport {
957
990
  if (thread.definition) metadata.definition = thread.definition;
958
991
  // The thread≡session record: persist the Claude session UUID onto the note so the
959
992
  // NEXT turn can `--resume` it. Prefer the session this write carries; else (a write
960
- // with no session, e.g. a start-phase working-ensure) PRESERVE the prior single-
961
- // threaded note's session so an upsert never drops continuity. Multi-threaded carries
962
- // its own per-fire session each write (no preserve — each fire is a fresh thread).
963
- const session = thread.session ?? (singleThreaded ? priorSession : undefined);
993
+ // with no session, e.g. a start-phase working-ensure) PRESERVE the prior DETERMINISTIC
994
+ // note's session so an upsert never drops continuity. A per-fire (no-subject multi)
995
+ // note carries its own per-fire session each write (no preserve — each fire is fresh).
996
+ const session = thread.session ?? (deterministic ? priorSession : undefined);
964
997
  if (session) metadata.session = session;
965
998
  // Usage is always present once a turn carried it OR we accumulated any — emit the
966
999
  // running totals so a query sees cumulative cost for the thread.
967
- if (singleThreaded || thread.usage) {
1000
+ if (deterministic || thread.usage) {
968
1001
  if (inputTokens) metadata.input_tokens = String(inputTokens);
969
1002
  if (outputTokens) metadata.output_tokens = String(outputTokens);
970
1003
  // Round the accumulated cost to 9 decimals before serializing — summing floats
@@ -1076,19 +1109,22 @@ export class VaultTransport implements Transport {
1076
1109
  }
1077
1110
  }
1078
1111
 
1079
- /** The persisted Claude session UUID for a single-threaded agent's deterministic
1080
- * thread note, or undefined if none yet (first turn). Read before a turn so the
1081
- * daemon can --resume it. */
1082
- async readThreadSession(channel: string, name: string): Promise<string | undefined> {
1083
- const prior = await this.readThreadNote(this.singleThreadedPath(channel, name));
1112
+ /** The persisted Claude session UUID for a thread's deterministic note, or undefined if
1113
+ * none yet (first turn). Read before a turn so the daemon can --resume it. `subject`
1114
+ * (roles×threads NEXT slice, #120) resolves the subject-scoped note
1115
+ * (`Threads/<ch>/<name>--<subject>`) for a multi-threaded subject thread; omitted → the
1116
+ * def-named note (HEAD). Uses {@link singleThreadedPath} so the path math matches writeThread. */
1117
+ async readThreadSession(channel: string, name: string, subject?: string): Promise<string | undefined> {
1118
+ const prior = await this.readThreadNote(this.singleThreadedPath(channel, name, subject));
1084
1119
  const s = prior?.metadata?.session;
1085
1120
  return typeof s === "string" && s ? s : undefined;
1086
1121
  }
1087
1122
 
1088
- /** Clear a single-threaded agent's persisted session so its next turn starts a
1089
- * fresh Claude conversation (the per-agent restart). No-op if no thread note yet. */
1090
- async clearThreadSession(channel: string, name: string): Promise<void> {
1091
- const path = this.singleThreadedPath(channel, name);
1123
+ /** Clear a thread's persisted session so its next turn starts a fresh Claude conversation
1124
+ * (the per-agent restart). `subject` resolves the subject-scoped note; omitted → the
1125
+ * def-named note (HEAD). No-op if no thread note yet. */
1126
+ async clearThreadSession(channel: string, name: string, subject?: string): Promise<void> {
1127
+ const path = this.singleThreadedPath(channel, name, subject);
1092
1128
  const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
1093
1129
  const res = await fetch(url, {
1094
1130
  method: "PATCH",
@@ -1319,8 +1355,21 @@ export class VaultTransport implements Transport {
1319
1355
  * thin wrapper over `writeInbound` so the inbound write path has ONE
1320
1356
  * implementation; only the default sender differs.
1321
1357
  */
1322
- async injectInbound(opts: { content: string; sender?: string }): Promise<{ id: string }> {
1323
- return this.writeInbound(opts.content, opts.sender ?? "runner");
1358
+ async injectInbound(opts: {
1359
+ content: string;
1360
+ sender?: string;
1361
+ /**
1362
+ * SUBJECT (roles×threads NOW slice) — the thread axis for a runner-fired turn.
1363
+ * When a job carries a subject, the runner threads it here; it's stamped onto the
1364
+ * inbound note's `metadata.subject` (via {@link writeInbound}'s `extraMeta`) so the
1365
+ * turn's composed prompt + (NEXT) thread routing can read it. Absent/empty → NO
1366
+ * `subject` field on the note (today's behavior exactly — the weave job is unaffected).
1367
+ */
1368
+ subject?: string;
1369
+ }): Promise<{ id: string }> {
1370
+ const subject = opts.subject?.trim();
1371
+ const extraMeta = subject ? { subject } : undefined;
1372
+ return this.writeInbound(opts.content, opts.sender ?? "runner", extraMeta);
1324
1373
  }
1325
1374
 
1326
1375
  /**
@@ -1579,6 +1628,10 @@ export class VaultTransport implements Transport {
1579
1628
  if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
1580
1629
  if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
1581
1630
  if (typeof m.lastStatus === "string") job.lastStatus = m.lastStatus;
1631
+ // roles×threads NOW slice: read the thread subject back (absent/blank → undefined).
1632
+ // Trim-guarded symmetrically with the write side (upsertJobNote) so a whitespace-only
1633
+ // value that somehow landed in the vault can't propagate downstream as a "subject".
1634
+ if (typeof m.subject === "string" && m.subject.trim()) job.subject = m.subject.trim();
1582
1635
  jobs.push(job);
1583
1636
  }
1584
1637
  return jobs;
@@ -1600,6 +1653,8 @@ export class VaultTransport implements Transport {
1600
1653
  createdAt: string;
1601
1654
  lastRunAt?: string;
1602
1655
  lastStatus?: string;
1656
+ /** The thread subject a fire carries (roles×threads NOW slice); absent → no field. */
1657
+ subject?: string;
1603
1658
  }): Promise<{ id: string }> {
1604
1659
  const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
1605
1660
  const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -1615,6 +1670,11 @@ export class VaultTransport implements Transport {
1615
1670
  if (job.tz) metadata.tz = job.tz;
1616
1671
  if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
1617
1672
  if (job.lastStatus) metadata.lastStatus = job.lastStatus;
1673
+ // roles×threads NOW slice: persist a non-empty subject; absent → no field (the weave
1674
+ // job writes none, so its note is byte-identical to HEAD).
1675
+ if (typeof job.subject === "string" && job.subject.trim().length > 0) {
1676
+ metadata.subject = job.subject.trim();
1677
+ }
1618
1678
 
1619
1679
  const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
1620
1680
  method: "POST",
@@ -1772,9 +1832,13 @@ export class VaultTransport implements Transport {
1772
1832
  }
1773
1833
  // Flatten the note's metadata into the inbound meta (string-valued), then
1774
1834
  // stamp our own provenance fields. `source`/`note_id`/`direction` are set
1775
- // explicitly so they win over anything in the note's metadata.
1835
+ // explicitly so they win over anything in the note's metadata. `subject` is
1836
+ // SKIPPED here and handled explicitly below (normalized to non-empty-or-absent),
1837
+ // so a blank `subject: " "` can't slip through the raw spread — absent and
1838
+ // whitespace-only must be indistinguishable downstream (the null-subject invariant).
1776
1839
  const flatMeta: Record<string, string> = {};
1777
1840
  for (const [k, v] of Object.entries(meta)) {
1841
+ if (k === "subject") continue;
1778
1842
  flatMeta[k] = typeof v === "string" ? v : String(v);
1779
1843
  }
1780
1844
 
@@ -1789,6 +1853,18 @@ export class VaultTransport implements Transport {
1789
1853
  attachments = await this.fetchInboundAttachments(note.id);
1790
1854
  }
1791
1855
 
1856
+ // SUBJECT (roles×threads NOW slice). The thread axis: when the inbound note
1857
+ // carries a non-empty string `metadata.subject`, surface it onto the emitted
1858
+ // event meta so it can flow through the queue → composed prompt → (NEXT) thread
1859
+ // routing. ABSENT/empty → no `subject` field on the emitted meta, so the emit is
1860
+ // BYTE-IDENTICAL to HEAD (the null-subject invariant — the weave path is untouched).
1861
+ // `subject` is deliberately SKIPPED in the `flatMeta` spread above and re-added here
1862
+ // ONLY when non-empty, so an empty/whitespace-only value can never leak through —
1863
+ // absent and blank are indistinguishable downstream.
1864
+ const rawSubject = meta.subject;
1865
+ const subject =
1866
+ typeof rawSubject === "string" && rawSubject.trim().length > 0 ? rawSubject : undefined;
1867
+
1792
1868
  this.ctx.emit({
1793
1869
  // `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
1794
1870
  // note metadata) — left as the channel name. The routing key rides in `meta.agent`.
@@ -1803,6 +1879,7 @@ export class VaultTransport implements Transport {
1803
1879
  note_id: note.id,
1804
1880
  sender: typeof meta.sender === "string" ? meta.sender : "",
1805
1881
  direction: "inbound",
1882
+ ...(subject ? { subject } : {}),
1806
1883
  },
1807
1884
  source: "vault",
1808
1885
  ...(attachments.length > 0 ? { attachments } : {}),