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