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