@openparachute/agent 0.2.3-rc.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.11",
3
+ "version": "0.2.3-rc.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",
@@ -442,6 +442,7 @@ export class ProgrammaticBackend implements AgentBackend {
442
442
  onInterim?: InterimSink,
443
443
  attachments?: InboundAttachment[],
444
444
  runContext?: RunContext,
445
+ subjectDossier?: string,
445
446
  ): Promise<DeliverResult> {
446
447
  const spec = handle.spec;
447
448
  if (!spec) {
@@ -587,10 +588,22 @@ export class ProgrammaticBackend implements AgentBackend {
587
588
  // Unset → no flag, no file (today's behavior unchanged). The `-file` form is
588
589
  // robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
589
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).
590
598
  let systemPromptFile: string | undefined;
591
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;
592
605
  systemPromptFile = join(workspace, "system-prompt.txt");
593
- writeFileSync(systemPromptFile, spec.systemPrompt, { mode: 0o600 });
606
+ writeFileSync(systemPromptFile, composed, { mode: 0o600 });
594
607
  }
595
608
 
596
609
  // The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
@@ -393,6 +393,16 @@ export interface QueuedMessage {
393
393
  * something else. Absent → the run context omits `fired-by`. Carries no routing meaning.
394
394
  */
395
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;
396
406
  }
397
407
 
398
408
  /** A registered programmatic agent's live status (surfaced in /health + the list). */
@@ -478,6 +488,16 @@ export class ProgrammaticAgentRegistry {
478
488
  * Unwired → reset is a clean no-op beyond returning that the agent exists.
479
489
  */
480
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>;
481
501
  /** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
482
502
  private readonly outboundRetryBaseMs: number;
483
503
 
@@ -491,6 +511,11 @@ export class ProgrammaticAgentRegistry {
491
511
  readSession?: (channel: string, name: string) => Promise<string | undefined>;
492
512
  /** Clear the persisted thread-note session (the per-agent restart / reset). */
493
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>;
494
519
  /** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
495
520
  outboundRetryBaseMs?: number;
496
521
  }) {
@@ -501,6 +526,7 @@ export class ProgrammaticAgentRegistry {
501
526
  if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
502
527
  if (deps.readSession) this.readSession = deps.readSession;
503
528
  if (deps.clearSession) this.clearSession = deps.clearSession;
529
+ if (deps.readSubjectDossier) this.readSubjectDossier = deps.readSubjectDossier;
504
530
  this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
505
531
  }
506
532
 
@@ -891,6 +917,24 @@ export class ProgrammaticAgentRegistry {
891
917
  ...(firedBy ? { firedBy } : {}),
892
918
  };
893
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
+ }
937
+
894
938
  let result;
895
939
  try {
896
940
  // Forward each interim event to the streaming-view sink (keyed by channel)
@@ -906,6 +950,9 @@ export class ProgrammaticAgentRegistry {
906
950
  msg.attachments,
907
951
  // agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
908
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,
909
956
  );
910
957
  } catch (err) {
911
958
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -244,6 +244,14 @@ 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).
247
255
  */
248
256
  deliver(
249
257
  handle: AgentHandle,
@@ -252,6 +260,7 @@ export interface AgentBackend {
252
260
  onInterim?: InterimSink,
253
261
  attachments?: InboundAttachment[],
254
262
  runContext?: RunContext,
263
+ subjectDossier?: string,
255
264
  ): Promise<DeliverResult>;
256
265
 
257
266
  /**
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
@@ -1815,7 +1823,12 @@ export function createFetchHandler(
1815
1823
  if (!transport) {
1816
1824
  return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
1817
1825
  }
1818
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
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
+ });
1819
1832
  return json({ ok: true, id, status: "ok" });
1820
1833
  } catch (err) {
1821
1834
  return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
@@ -3381,7 +3394,14 @@ function main(): void {
3381
3394
  if (!transport) {
3382
3395
  throw new Error(`channel "${job.channel}" is not a live vault channel`);
3383
3396
  }
3384
- await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
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
+ });
3385
3405
  },
3386
3406
  // Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
3387
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
  }
@@ -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
+ }
@@ -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
  /**
@@ -1319,8 +1331,21 @@ export class VaultTransport implements Transport {
1319
1331
  * thin wrapper over `writeInbound` so the inbound write path has ONE
1320
1332
  * implementation; only the default sender differs.
1321
1333
  */
1322
- async injectInbound(opts: { content: string; sender?: string }): Promise<{ id: string }> {
1323
- return this.writeInbound(opts.content, opts.sender ?? "runner");
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);
1324
1349
  }
1325
1350
 
1326
1351
  /**
@@ -1579,6 +1604,10 @@ export class VaultTransport implements Transport {
1579
1604
  if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
1580
1605
  if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
1581
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();
1582
1611
  jobs.push(job);
1583
1612
  }
1584
1613
  return jobs;
@@ -1600,6 +1629,8 @@ export class VaultTransport implements Transport {
1600
1629
  createdAt: string;
1601
1630
  lastRunAt?: string;
1602
1631
  lastStatus?: string;
1632
+ /** The thread subject a fire carries (roles×threads NOW slice); absent → no field. */
1633
+ subject?: string;
1603
1634
  }): Promise<{ id: string }> {
1604
1635
  const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
1605
1636
  const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -1615,6 +1646,11 @@ export class VaultTransport implements Transport {
1615
1646
  if (job.tz) metadata.tz = job.tz;
1616
1647
  if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
1617
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
+ }
1618
1654
 
1619
1655
  const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
1620
1656
  method: "POST",
@@ -1772,9 +1808,13 @@ export class VaultTransport implements Transport {
1772
1808
  }
1773
1809
  // Flatten the note's metadata into the inbound meta (string-valued), then
1774
1810
  // stamp our own provenance fields. `source`/`note_id`/`direction` are set
1775
- // 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).
1776
1815
  const flatMeta: Record<string, string> = {};
1777
1816
  for (const [k, v] of Object.entries(meta)) {
1817
+ if (k === "subject") continue;
1778
1818
  flatMeta[k] = typeof v === "string" ? v : String(v);
1779
1819
  }
1780
1820
 
@@ -1789,6 +1829,18 @@ export class VaultTransport implements Transport {
1789
1829
  attachments = await this.fetchInboundAttachments(note.id);
1790
1830
  }
1791
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
+
1792
1844
  this.ctx.emit({
1793
1845
  // `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
1794
1846
  // note metadata) — left as the channel name. The routing key rides in `meta.agent`.
@@ -1803,6 +1855,7 @@ export class VaultTransport implements Transport {
1803
1855
  note_id: note.id,
1804
1856
  sender: typeof meta.sender === "string" ? meta.sender : "",
1805
1857
  direction: "inbound",
1858
+ ...(subject ? { subject } : {}),
1806
1859
  },
1807
1860
  source: "vault",
1808
1861
  ...(attachments.length > 0 ? { attachments } : {}),