@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 +1 -1
- package/src/backends/programmatic.ts +7 -1
- package/src/backends/registry.ts +181 -40
- package/src/backends/types.ts +9 -0
- package/src/daemon.ts +19 -6
- package/src/spawn-agent.ts +13 -3
- package/src/transport.ts +23 -11
- package/src/transports/vault.ts +80 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
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
|
-
|
|
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.
|
package/src/backends/registry.ts
CHANGED
|
@@ -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
|
-
* -
|
|
314
|
-
*
|
|
315
|
-
* `
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* -
|
|
320
|
-
*
|
|
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
|
-
|
|
334
|
+
perFire: boolean,
|
|
327
335
|
turnThreadId: string,
|
|
328
336
|
threadNoteId: string | undefined,
|
|
329
337
|
): string {
|
|
330
|
-
if (
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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?: (
|
|
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
|
-
/**
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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(
|
|
810
|
-
// Start the worker if it isn't already running. The drain promise's
|
|
811
|
-
// `draining` is the "a worker is running
|
|
812
|
-
// await so a second enqueue
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
|
|
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,
|
package/src/backends/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"`.
|
package/src/spawn-agent.ts
CHANGED
|
@@ -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
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* without a durable thread store
|
|
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
|
package/src/transports/vault.ts
CHANGED
|
@@ -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
|
|
824
|
-
* `Threads/<safeChannel>/<
|
|
825
|
-
*
|
|
826
|
-
* {@link
|
|
827
|
-
*
|
|
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
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
// multi-threaded
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
|
|
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
|
|
888
|
-
// path) to roll up the aggregates. SAFE
|
|
889
|
-
//
|
|
890
|
-
// concurrent writer to lose an update against.
|
|
891
|
-
// WHEN
|
|
892
|
-
//
|
|
893
|
-
//
|
|
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 (
|
|
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 =
|
|
936
|
-
} else if (
|
|
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:
|
|
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
|
-
(
|
|
971
|
+
(deterministic ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
|
|
951
972
|
const outputTokens =
|
|
952
|
-
(
|
|
953
|
-
const costUsd = (
|
|
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
|
|
973
|
-
//
|
|
974
|
-
// its own per-fire session each write (no preserve — each fire is
|
|
975
|
-
const session = thread.session ?? (
|
|
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 (
|
|
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
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
|
1101
|
-
*
|
|
1102
|
-
|
|
1103
|
-
|
|
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",
|