@openparachute/agent 0.2.3-rc.12 → 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.12",
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",
@@ -443,13 +443,19 @@ export class ProgrammaticBackend implements AgentBackend {
443
443
  attachments?: InboundAttachment[],
444
444
  runContext?: RunContext,
445
445
  subjectDossier?: string,
446
+ subject?: string,
446
447
  ): Promise<DeliverResult> {
447
448
  const spec = handle.spec;
448
449
  if (!spec) {
449
450
  return { ok: false, error: `ProgrammaticBackend.deliver: handle for "${handle.name}" carries no spec` };
450
451
  }
451
452
  const channel = handle.channel;
452
- 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);
453
459
 
454
460
  // Resolve the Claude OAuth credential keyed on the wake channel. A missing
455
461
  // credential throws (CredentialNotConfigured) BEFORE any mint/spawn side effect.
@@ -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
 
@@ -437,9 +445,25 @@ export class ProgrammaticAgentRegistry {
437
445
  private readonly byChannel = new Map<string, ProgrammaticAgentHandle>();
438
446
  /** name → channel (the lifecycle index; an agent has exactly one wake channel). */
439
447
  private readonly nameToChannel = new Map<string, string>();
440
- /** 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
+ */
441
459
  private readonly queues = new Map<string, QueuedMessage[]>();
442
- /** 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
+ */
443
467
  private readonly draining = new Map<string, Promise<void>>();
444
468
  /**
445
469
  * channel → FIFO queue of PENDING-INBOUND messages that arrived BEFORE a live
@@ -480,14 +504,18 @@ export class ProgrammaticAgentRegistry {
480
504
  * 2+ `--resume`s its prior conversation. Unwired (or no prior) → every turn creates a
481
505
  * fresh session. Multi-threaded NEVER consults it (each fire is a fresh thread).
482
506
  */
483
- 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>;
484
512
  /**
485
513
  * Optional session CLEAR — wipe a single-threaded agent's persisted thread-note session
486
514
  * so its next turn starts a FRESH claude conversation (the per-agent restart). The daemon
487
515
  * wires this to the channel transport's `clearThreadSession`. Called by {@link resetSession}.
488
516
  * Unwired → reset is a clean no-op beyond returning that the agent exists.
489
517
  */
490
- private readonly clearSession?: (channel: string, name: string) => Promise<void>;
518
+ private readonly clearSession?: (channel: string, name: string, subject?: string) => Promise<void>;
491
519
  /**
492
520
  * Optional pre-turn SUBJECT-DOSSIER read (roles×threads NOW slice) — per-thread
493
521
  * context for the current subject, folded into the composed system prompt (the
@@ -507,10 +535,17 @@ export class ProgrammaticAgentRegistry {
507
535
  writeThread?: WriteThread;
508
536
  writeCallback?: WriteCallback;
509
537
  onTurnEvent?: TurnEventSink;
510
- /** Read the persisted thread-note session UUID (single-threaded resume). */
511
- readSession?: (channel: string, name: string) => Promise<string | undefined>;
512
- /** Clear the persisted thread-note session (the per-agent restart / reset). */
513
- 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>;
514
549
  /**
515
550
  * Read the per-thread subject dossier (roles×threads NOW slice). Optional + UNWIRED
516
551
  * by default — the composed prompt is the role body verbatim until a dossier source exists.
@@ -552,6 +587,59 @@ export class ProgrammaticAgentRegistry {
552
587
  return normalizeChannel(spec.channels[0]!).name;
553
588
  }
554
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
+
555
643
  /** Is a programmatic agent registered for this channel? (the inbound-routing check) */
556
644
  hasChannel(channel: string): boolean {
557
645
  return this.byChannel.has(channel);
@@ -584,6 +672,10 @@ export class ProgrammaticAgentRegistry {
584
672
  * /health + the agents list to render `programmatic · idle|working|queued:N`.
585
673
  */
586
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).
587
679
  const queued = this.queues.get(channel)?.length ?? 0;
588
680
  if (this.draining.has(channel)) {
589
681
  // A worker is in flight. If there are messages waiting BEHIND the in-flight
@@ -617,9 +709,13 @@ export class ProgrammaticAgentRegistry {
617
709
  // channel's EXPECTED mark + any stranded pending buffer — nothing routes there now,
618
710
  // so a residual mark/buffer would leak (reviewer nit; defense-in-depth — the normal
619
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);
620
718
  this.byChannel.delete(priorChannel);
621
- this.queues.delete(priorChannel);
622
- this.draining.delete(priorChannel);
623
719
  this.expectedChannels.delete(priorChannel);
624
720
  this.pending.delete(priorChannel);
625
721
  }
@@ -740,9 +836,13 @@ export class ProgrammaticAgentRegistry {
740
836
  const channel = this.nameToChannel.get(name);
741
837
  if (channel === undefined) return false;
742
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);
743
844
  this.byChannel.delete(channel);
744
845
  this.nameToChannel.delete(name);
745
- this.queues.delete(channel);
746
846
  // Clear the EXPECTED mark + any buffered pending inbound for this channel too —
747
847
  // the agent is gone, so a pending message has nothing to drain into and would
748
848
  // strand forever (and the next register would replay stale messages). The daemon's
@@ -804,17 +904,26 @@ export class ProgrammaticAgentRegistry {
804
904
  */
805
905
  enqueue(channel: string, msg: QueuedMessage): boolean {
806
906
  if (!this.byChannel.has(channel)) return false;
807
- 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) ?? [];
808
915
  queue.push(msg);
809
- this.queues.set(channel, queue);
810
- // Start the worker if it isn't already running. The drain promise's PRESENCE in
811
- // `draining` is the "a worker is running" flag — set it synchronously before any
812
- // await so a second enqueue in the same tick can't start a second worker.
813
- if (!this.draining.has(channel)) {
814
- const p = this.drain(channel).finally(() => {
815
- 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);
816
925
  });
817
- this.draining.set(channel, p);
926
+ this.draining.set(drainKey, p);
818
927
  }
819
928
  return true;
820
929
  }
@@ -833,9 +942,13 @@ export class ProgrammaticAgentRegistry {
833
942
  * failure is logged; the turn still counts as drained (the reply is durable-or-not
834
943
  * at the transport's discretion; we don't re-run the turn, which would fork).
835
944
  */
836
- private async drain(channel: string): Promise<void> {
945
+ private async drain(drainKey: string, channel: string): Promise<void> {
837
946
  for (;;) {
838
- 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);
839
952
  if (!queue || queue.length === 0) return;
840
953
  const handle = this.byChannel.get(channel);
841
954
  if (!handle) return; // deregistered mid-drain — stop.
@@ -860,9 +973,23 @@ export class ProgrammaticAgentRegistry {
860
973
  // CREATE a fresh session with a new uuid (`--session-id`). The backend just runs the
861
974
  // turn with this {@link TurnSession}; it reads no session store.
862
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).
863
990
  let resumeId: string | undefined;
864
- if (!multiThreaded && this.readSession) {
865
- 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);
866
993
  }
867
994
  const turnSession: TurnSession = resumeId
868
995
  ? { id: resumeId, resume: true }
@@ -897,10 +1024,13 @@ export class ProgrammaticAgentRegistry {
897
1024
  // single-threaded resume turn the prior session is preserved by writeThread anyway.
898
1025
  });
899
1026
  // 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);
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);
904
1034
 
905
1035
  // RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
906
1036
  // know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
@@ -953,6 +1083,11 @@ export class ProgrammaticAgentRegistry {
953
1083
  // roles×threads NOW slice: the per-thread subject dossier, folded into the composed
954
1084
  // system prompt. Undefined (no subject / unwired source) → role body verbatim.
955
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,
956
1091
  );
957
1092
  } catch (err) {
958
1093
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -1245,6 +1380,12 @@ export class ProgrammaticAgentRegistry {
1245
1380
  const thread: ThreadNote = {
1246
1381
  channel: handle.channel,
1247
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 } : {}),
1248
1389
  ...(handle.spec.definition ? { definition: handle.spec.definition } : {}),
1249
1390
  mode: handle.spec.mode ?? "single-threaded",
1250
1391
  status,
@@ -252,6 +252,14 @@ export interface AgentBackend {
252
252
  * subject-specific context layers on top. The run-context preamble is unaffected (it stays on
253
253
  * the MESSAGE). ADDITIVE — omitted/empty → the system prompt is the role body verbatim
254
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.
255
263
  */
256
264
  deliver(
257
265
  handle: AgentHandle,
@@ -261,6 +269,7 @@ export interface AgentBackend {
261
269
  attachments?: InboundAttachment[],
262
270
  runContext?: RunContext,
263
271
  subjectDossier?: string,
272
+ subject?: string,
264
273
  ): Promise<DeliverResult>;
265
274
 
266
275
  /**
package/src/daemon.ts CHANGED
@@ -838,11 +838,14 @@ export function buildWriteCallback(channels: Map<string, Channel>): WriteCallbac
838
838
  */
839
839
  export function buildReadSession(
840
840
  channels: Map<string, Channel>,
841
- ): (channel: string, name: string) => Promise<string | undefined> {
842
- return async (channel, name) => {
841
+ ): (channel: string, name: string, subject?: string) => Promise<string | undefined> {
842
+ return async (channel, name, subject) => {
843
843
  const ch = channels.get(channel);
844
844
  if (!ch?.transport.readThreadSession) return undefined;
845
- 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);
846
849
  };
847
850
  }
848
851
 
@@ -856,11 +859,12 @@ export function buildReadSession(
856
859
  */
857
860
  export function buildClearSession(
858
861
  channels: Map<string, Channel>,
859
- ): (channel: string, name: string) => Promise<void> {
860
- return async (channel, name) => {
862
+ ): (channel: string, name: string, subject?: string) => Promise<void> {
863
+ return async (channel, name, subject) => {
861
864
  const ch = channels.get(channel);
862
865
  if (!ch?.transport.clearThreadSession) return;
863
- 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);
864
868
  };
865
869
  }
866
870
 
@@ -1076,6 +1080,15 @@ export async function reregisterProgrammaticAgents(
1076
1080
  }
1077
1081
  let count = 0;
1078
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;
1079
1092
  const workspace = sessionWorkspace(sessionsDirPath, name);
1080
1093
  const spec = readPersistedSpec(workspace);
1081
1094
  // Re-register ONLY specs that explicitly persisted `backend: "programmatic"`.
@@ -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`
@@ -820,20 +824,33 @@ export class VaultTransport implements Transport {
820
824
  }
821
825
 
822
826
  /**
823
- * The DETERMINISTIC path of a single-threaded agent's ONE thread note
824
- * `Threads/<safeChannel>/<safeName>` (named after the def). The single shared
825
- * source of truth for that path so {@link writeThread} (the upsert) and
826
- * {@link readThreadSession} (the pre-turn session read) can never disagree on
827
- * 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.
828
832
  *
829
- * COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
830
- * on the same channel would share this note. Acceptable because the registry enforces
831
- * ONE agent per channel (byChannel index), so the collision can't arise in practice.
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.
839
+ *
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.
832
843
  */
833
- private singleThreadedPath(channel: string, name: string): string {
844
+ private singleThreadedPath(channel: string, name: string, subject?: string): string {
834
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).
835
851
  const safeName = (name ?? channel).replace(/[^a-zA-Z0-9_-]/g, "-");
836
- return `${THREAD_PATH_PREFIX}/${safeChannel}/${safeName}`;
852
+ const leaf = threadKey(safeName, subject);
853
+ return `${THREAD_PATH_PREFIX}/${safeChannel}/${leaf}`;
837
854
  }
838
855
 
839
856
  /**
@@ -865,32 +882,36 @@ export class VaultTransport implements Transport {
865
882
  async writeThread(thread: ThreadRecord): Promise<{ sent: string[] }> {
866
883
  const safeChannel = thread.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
867
884
  const singleThreaded = thread.mode === "single-threaded";
868
-
869
- // IDENTITY by mode (HARD CONSTRAINT 3 the path leaf IS the thread's identity; no
870
- // ambiguous `thread_id` metadata field). single-threaded: a DETERMINISTIC leaf named
871
- // after the def (the agent/spec name, sanitized) so the SAME note upserts across turns.
872
- // multi-threaded: a fresh uuid per fire (one fire = one thread = one note today).
873
- // COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
874
- // on the same channel would upsert each other's thread note. Acceptable because the
875
- // registry enforces ONE agent per channel (byChannel index), so the collision can't
876
- // arise in practice.
877
- // Multi-threaded leaf: a per-FIRE id. Reuse the caller's `threadId` when given (a
878
- // re-record of the same turn e.g. the outbound-failure status flip targets the
879
- // SAME per-fire note instead of minting a duplicate); else mint a fresh one. Single-
880
- // threaded uses the DETERMINISTIC path (named after the def) so the one-per-channel
881
- // note upserts computed via {@link singleThreadedPath} so writeThread and
882
- // readThreadSession agree on exactly where the note lives.
883
- const path = singleThreaded
884
- ? 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)
885
906
  : `${THREAD_PATH_PREFIX}/${safeChannel}/${thread.threadId ?? crypto.randomUUID()}`;
886
907
 
887
- // For single-threaded UPSERT, read the existing thread note (by its deterministic
888
- // path) to roll up the aggregates. SAFE because the drain is serial per channel and
889
- // single-threaded is one-thread-per-channel today (see the method doc) — there's no
890
- // concurrent writer to lose an update against.
891
- // WHEN CONTINUATION brings concurrent threads per channel, switch to re-deriving
892
- // aggregates from the #agent/message children or a vault atomic-merge, to avoid
893
- // 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.
894
915
  let priorTurnCount = 0;
895
916
  let priorInputTokens = 0;
896
917
  let priorOutputTokens = 0;
@@ -898,7 +919,7 @@ export class VaultTransport implements Transport {
898
919
  let priorStartedAt: string | undefined;
899
920
  let priorLastTurnAt: string | undefined;
900
921
  let priorSession: string | undefined;
901
- if (singleThreaded) {
922
+ if (deterministic) {
902
923
  const prior = await this.readThreadNote(path);
903
924
  if (prior) {
904
925
  priorTurnCount = numFromMeta(prior.metadata?.turn_count);
@@ -932,8 +953,8 @@ export class VaultTransport implements Transport {
932
953
  const isStart = thread.phase === "start";
933
954
  let turnCount: number;
934
955
  if (isStart) {
935
- turnCount = singleThreaded ? priorTurnCount : 0;
936
- } else if (singleThreaded) {
956
+ turnCount = deterministic ? priorTurnCount : 0;
957
+ } else if (deterministic) {
937
958
  turnCount = thread.sameTurn ? priorTurnCount : priorTurnCount + 1;
938
959
  } else {
939
960
  turnCount = 1;
@@ -944,13 +965,13 @@ export class VaultTransport implements Transport {
944
965
  const startedAt = priorStartedAt ?? thread.started_at;
945
966
  const lastTurnAt = isStart ? (priorLastTurnAt ?? "") : thread.ended_at;
946
967
 
947
- // Cumulative usage: single-threaded SUMS this turn into the prior totals; multi-threaded
948
- // 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).
949
970
  const inputTokens =
950
- (singleThreaded ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
971
+ (deterministic ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
951
972
  const outputTokens =
952
- (singleThreaded ? priorOutputTokens : 0) + (thread.usage?.outputTokens ?? 0);
953
- 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);
954
975
 
955
976
  // Indexed string fields (queryable) + the thread-state observability fields. The
956
977
  // vault stores metadata as strings; numbers are stringified.
@@ -969,14 +990,14 @@ export class VaultTransport implements Transport {
969
990
  if (thread.definition) metadata.definition = thread.definition;
970
991
  // The thread≡session record: persist the Claude session UUID onto the note so the
971
992
  // NEXT turn can `--resume` it. Prefer the session this write carries; else (a write
972
- // with no session, e.g. a start-phase working-ensure) PRESERVE the prior single-
973
- // threaded note's session so an upsert never drops continuity. Multi-threaded carries
974
- // its own per-fire session each write (no preserve — each fire is a fresh thread).
975
- 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);
976
997
  if (session) metadata.session = session;
977
998
  // Usage is always present once a turn carried it OR we accumulated any — emit the
978
999
  // running totals so a query sees cumulative cost for the thread.
979
- if (singleThreaded || thread.usage) {
1000
+ if (deterministic || thread.usage) {
980
1001
  if (inputTokens) metadata.input_tokens = String(inputTokens);
981
1002
  if (outputTokens) metadata.output_tokens = String(outputTokens);
982
1003
  // Round the accumulated cost to 9 decimals before serializing — summing floats
@@ -1088,19 +1109,22 @@ export class VaultTransport implements Transport {
1088
1109
  }
1089
1110
  }
1090
1111
 
1091
- /** The persisted Claude session UUID for a single-threaded agent's deterministic
1092
- * thread note, or undefined if none yet (first turn). Read before a turn so the
1093
- * daemon can --resume it. */
1094
- async readThreadSession(channel: string, name: string): Promise<string | undefined> {
1095
- 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));
1096
1119
  const s = prior?.metadata?.session;
1097
1120
  return typeof s === "string" && s ? s : undefined;
1098
1121
  }
1099
1122
 
1100
- /** Clear a single-threaded agent's persisted session so its next turn starts a
1101
- * fresh Claude conversation (the per-agent restart). No-op if no thread note yet. */
1102
- async clearThreadSession(channel: string, name: string): Promise<void> {
1103
- 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);
1104
1128
  const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
1105
1129
  const res = await fetch(url, {
1106
1130
  method: "PATCH",