@openparachute/agent 0.2.3-rc.10 → 0.2.3-rc.11

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.10",
3
+ "version": "0.2.3-rc.11",
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,7 @@ export class ProgrammaticBackend implements AgentBackend {
414
441
  session: TurnSession,
415
442
  onInterim?: InterimSink,
416
443
  attachments?: InboundAttachment[],
444
+ runContext?: RunContext,
417
445
  ): Promise<DeliverResult> {
418
446
  const spec = handle.spec;
419
447
  if (!spec) {
@@ -537,13 +565,18 @@ export class ProgrammaticBackend implements AgentBackend {
537
565
  // per-agent even when the working dir is shared. Best-effort + isolated: a single
538
566
  // attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
539
567
  // + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
540
- let turnMessage = message;
568
+ // RUN CONTEXT (agent#162): prepend the daemon-injected runtime preamble (the real
569
+ // wall-clock + new/resumed + why-it-fired) so the headless turn reads ACCURATE facts
570
+ // instead of fabricating a clock. Done FIRST so the preamble sits at the very top of the
571
+ // prompt; attachments append after the (already-prefixed) message. Absent runContext →
572
+ // the message is unchanged (additive).
573
+ let turnMessage = renderRunContext(message, runContext);
541
574
  if (attachments && attachments.length > 0) {
542
575
  const staged = await this.stageAttachments(workspace, attachments, vaultArg);
543
576
  if (staged.length > 0) {
544
577
  const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
545
578
  turnMessage =
546
- `${message}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
579
+ `${turnMessage}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
547
580
  }
548
581
  }
549
582
 
@@ -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 per-turn thread id this reply belongs to — the explicit definition→thread→message
94
- * link the outbound note carries (stamped into `metadata.thread`). For multi-threaded it
95
- * IS the per-fire thread note's leaf (an exact link); for single-threaded it's a per-turn
96
- * correlation id (the note's stable deterministic leaf is the def name single-threaded
97
- * outbound→note linkage by the stable path is a follow-up). INBOUND-note stamping is
98
- * deferred (those notes are externally written; see the PR notes).
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,13 @@ 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;
346
396
  }
347
397
 
348
398
  /** A registered programmatic agent's live status (surfaced in /health + the list). */
@@ -806,7 +856,11 @@ export class ProgrammaticAgentRegistry {
806
856
  // start+end never double-count. Best-effort: a start-ensure write failure is logged
807
857
  // (inside recordThread) and the turn STILL runs — a missing/stale working note must
808
858
  // never strand the queue or skip the turn.
809
- await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
859
+ // Capture the start-ensure's WRITTEN thread-note id. For single-threaded this is the
860
+ // DETERMINISTIC `Threads/<safeChannel>/<safeName>` note (the same every turn) — so a
861
+ // resolvable thread id is available BEFORE the turn runs, for the failure-note +
862
+ // outbound `metadata.thread` stamping (agent#163), even on a turn that fails early.
863
+ const startThreadNoteId = await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
810
864
  threadId: turnThreadId,
811
865
  phase: "start",
812
866
  // NO session on the start-ensure: it runs BEFORE claude, so claude may never
@@ -816,6 +870,26 @@ export class ProgrammaticAgentRegistry {
816
870
  // the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
817
871
  // single-threaded resume turn the prior session is preserved by writeThread anyway.
818
872
  });
873
+ // The MODE-CORRECT, RESOLVABLE thread id stamped into outbound + failure notes
874
+ // (agent#163): single-threaded → the deterministic thread-NOTE id (stable across turns);
875
+ // multi-threaded → the per-fire `turnThreadId`. Computed once from the start-ensure id so
876
+ // every path (early failure, ok, outbound-failure) stamps the same resolvable link.
877
+ const outThreadId = outboundThreadId(multiThreaded, turnThreadId, startThreadNoteId);
878
+
879
+ // RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
880
+ // know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
881
+ // (`turnSession.resume`) or starts fresh, and WHY it fired (a scheduled job vs an
882
+ // interactive/delegated message, from the inbound sender). The backend prepends these as
883
+ // a labeled preamble so the agent stamps ACCURATE times instead of fabricating them.
884
+ // (DEFERRED: `priorTurnCount` — the rolling turn_count lives in the transport's thread
885
+ // note and isn't surfaced back to the drain; wiring it is a follow-up. Until then the
886
+ // preamble omits the `turn=N` line — the `now`/session/fired-by trio is the floor.)
887
+ const firedBy = runFiredBy(msg.sender);
888
+ const runContext: RunContext = {
889
+ now: startedAt,
890
+ session: turnSession.resume ? "resumed" : "new",
891
+ ...(firedBy ? { firedBy } : {}),
892
+ };
819
893
 
820
894
  let result;
821
895
  try {
@@ -830,6 +904,8 @@ export class ProgrammaticAgentRegistry {
830
904
  // Phase 1: inbound attachments → the programmatic backend stages them into the
831
905
  // agent's private workspace so the turn can Read them. Absent/empty → no staging.
832
906
  msg.attachments,
907
+ // agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
908
+ runContext,
833
909
  );
834
910
  } catch (err) {
835
911
  // The backend contract is failure-as-VALUE, never a throw — but defend so a
@@ -854,8 +930,8 @@ export class ProgrammaticAgentRegistry {
854
930
  });
855
931
  this.emitTurnEvent(channel, { kind: "error", error: reason });
856
932
  // 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, turnThreadId, reason);
933
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
934
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, reason);
859
935
  // CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
860
936
  // hang waiting forever. No outbound note was produced, so no `source_message`; the
861
937
  // RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
@@ -886,8 +962,8 @@ export class ProgrammaticAgentRegistry {
886
962
  });
887
963
  this.emitTurnEvent(channel, { kind: "error", error: result.error });
888
964
  // 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, turnThreadId, result.error);
965
+ // no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
966
+ await this.postFailureNote(channel, msg.inReplyTo, outThreadId, result.error);
891
967
  // CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
892
968
  // sub-task failed and can react. No delivered reply, so no `source_message`; the
893
969
  // RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
@@ -931,7 +1007,12 @@ export class ProgrammaticAgentRegistry {
931
1007
  channel,
932
1008
  result.reply,
933
1009
  msg.inReplyTo,
934
- turnThreadId,
1010
+ // agent#163: stamp the MODE-CORRECT, RESOLVABLE thread id into the outbound note's
1011
+ // `metadata.thread` — single-threaded → the deterministic thread-NOTE id (stable
1012
+ // across turns; an observer resolves the ONE thread), multi-threaded → the per-fire
1013
+ // id. NOT the per-turn correlation UUID (the pre-#163 bug that looked like a fresh
1014
+ // session every run).
1015
+ outThreadId,
935
1016
  );
936
1017
  if (delivered.ok) sourceMessage = delivered.noteId;
937
1018
  if (!delivered.ok) {
@@ -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,12 @@ 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.
204
247
  */
205
248
  deliver(
206
249
  handle: AgentHandle,
@@ -208,6 +251,7 @@ export interface AgentBackend {
208
251
  session: TurnSession,
209
252
  onInterim?: InterimSink,
210
253
  attachments?: InboundAttachment[],
254
+ runContext?: RunContext,
211
255
  ): Promise<DeliverResult>;
212
256
 
213
257
  /**
package/src/daemon.ts CHANGED
@@ -342,6 +342,9 @@ 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 } : {}),
345
348
  });
346
349
  return;
347
350
  }
@@ -366,6 +369,9 @@ export function contextFor(
366
369
  // Phase 1: carry inbound attachments through the pending buffer too, so a turn
367
370
  // that runs on register() still stages them.
368
371
  ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
372
+ // agent#162: carry the sender through the pending buffer too, so a turn that runs on
373
+ // register() still derives the right run-context `fired-by`.
374
+ ...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
369
375
  });
370
376
  if (outcome === "queued") return;
371
377
  // outcome === "unknown" — not an expected programmatic channel. It may still be a
@@ -761,11 +761,14 @@ export class VaultTransport implements Transport {
761
761
  // Thread the reply to the inbound note id when the bridge passes it through.
762
762
  const inReplyTo = args.meta?.in_reply_to;
763
763
  if (inReplyTo) metadata.in_reply_to = inReplyTo;
764
- // The explicit definition→thread→message link: stamp the outbound note with its thread
765
- // id (the programmatic worker passes the per-turn thread id through `meta.thread`). For a
766
- // multi-threaded turn this IS the per-fire `#agent/thread` note's leaf; for a
767
- // single-threaded turn it's a per-turn correlation id. INBOUND-note stamping is deferred
768
- // (those notes are written externally, before the turn knows its thread).
764
+ // The explicit definition→thread→message link: stamp the outbound note with its
765
+ // RESOLVABLE, mode-correct thread id (the programmatic worker passes it through
766
+ // `meta.thread`, agent#163). For a multi-threaded turn this IS the per-fire `#agent/thread`
767
+ // note's leaf; for a single-threaded turn it's the DETERMINISTIC thread-NOTE id
768
+ // (`Threads/<channel>/<name>`), STABLE across turns so an observer resolves the agent's
769
+ // ONE thread from `metadata.thread`, not a per-turn UUID that changed every run.
770
+ // INBOUND-note stamping is deferred (those notes are written externally, before the turn
771
+ // knows its thread).
769
772
  const threadId = args.meta?.thread;
770
773
  if (threadId) metadata.thread = threadId;
771
774