@jini-ai/daemon 0.6.9 → 0.6.10

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/CHANGELOG.md CHANGED
@@ -1,3 +1,147 @@
1
+ ## 0.6.10
2
+
3
+ ### Patch Changes
4
+
5
+ - 4f52784: Stop advertising agents `AgentExecutor` cannot run.
6
+
7
+ The built-in `agents` feature scanned every runtime definition, but `AgentExecutor` rejects some of
8
+ them (`antigravity`, whose deferral is documented in that module). A consumer could therefore offer a
9
+ user an agent that failed the instant it was selected, with the mismatch visible nowhere.
10
+
11
+ - `@jini-ai/daemon` exports `isAgentExecutorSupported(def)` and `assessAgentExecutorCompatibility(def)`.
12
+ `run()` now consumes the latter instead of re-checking the conditions itself, so the discovery-time
13
+ answer and the run-time guards cannot drift apart. Error messages are unchanged.
14
+ - `@jini-ai/server`'s `agents` feature applies the predicate by default, and exports
15
+ `isExecutableDetectedAgent` for hosts that want it directly.
16
+
17
+ `antigravity`'s definition remains in `@jini-ai/agent-runtime` — the predicate is advisory, and a
18
+ non-`AgentExecutor` launch path can ignore it. An id with no registered def is kept rather than
19
+ dropped, so a host supplying its own `detector` is unaffected.
20
+
21
+ Note for anyone implementing something similar: the predicate deliberately takes the full
22
+ `RuntimeAgentDef`, not a projected `DetectedAgent`. That projection omits `maxPromptArgBytes`, and the
23
+ argv-bound defs (`aider`, `deepseek`) qualify solely through it — judging the projection would have
24
+ dropped two working agents while fixing one broken one.
25
+
26
+ - 8ff5653: Drive Antigravity (`agy`) — the 24th and last agent definition `AgentExecutor` rejected.
27
+
28
+ `AgentExecutor` drove 23 of 24 registered definitions. Antigravity was rejected by an explicit
29
+ `def.id === 'antigravity'` guard, for two reasons that are real but narrow:
30
+
31
+ - **`agy` can print an OAuth sign-in URL to stdout and still exit 0.** Streaming stdout live shows
32
+ that URL to the user as if it were the model's reply — a URL that carries the daemon operator's
33
+ `client_id`/`redirect_uri` into a chat transcript, and that is useless to click anyway (`agy -p`
34
+ print mode has no field to paste the resulting auth code into).
35
+ - **`agy` has no `--model` flag.** The model choice is written into one process-global
36
+ `~/.gemini/antigravity-cli/settings.json` that `agy` reads on its own startup, so two concurrent
37
+ runs race: run A writes model A, A spawns, B writes model B, and only _then_ does A's `agy` read
38
+ the file — so A silently executes on B's model.
39
+
40
+ Both are now met by **declarative `RuntimeAgentDef` fields the executor reads generically**, joining
41
+ the 14 optional behavior flags (`promptViaFile`, `authProbe`, …) that already work this way. There is
42
+ no agent-id branch anywhere in the dispatch path — a deliberate divergence from Open Design's own
43
+ daemon, which hardcodes the id twice.
44
+
45
+ New on `@jini-ai/agent-runtime`'s `RuntimeAgentDef`:
46
+
47
+ - `needsAgentLogFile?: boolean` — asks the caller to stage a temp path and pass it as
48
+ `RuntimeContext.agentLogFilePath` before `buildArgs` runs, mirroring `promptViaFile`. Staged by the
49
+ new `prepareAgentLogFile(def, label)` (the sibling of `preparePromptFileForAgent`; it creates only
50
+ the `0o700` containing directory, since the log file is output the CLI authors, not input we do).
51
+ - `stdoutPolicy?: RuntimeStdoutPolicy` — `{buffering: 'live'}` (the default, and every other
52
+ definition's behavior) or `{buffering: 'until-close', sanitize?}`. A discriminated union rather
53
+ than two flat fields on purpose: a sanitizer is only meaningful on the buffered path, because the
54
+ pattern to redact can straddle two `'data'` chunks. Two independent flags would let a definition
55
+ declare a sanitizer the caller could not honor — a confidentiality gap that _looks_ closed.
56
+ - `runtimeLock?: RuntimeLock` — a mutex around a process-global side effect `buildArgs` performs.
57
+ Acquired before `buildArgs`, released on whichever of `waitForHandoff` settling or process exit
58
+ comes first. Releasing on exit is load-bearing, not a fallback: a watcher that stops polling means
59
+ "I stopped watching", never "the child definitely didn't read the file".
60
+
61
+ All three are stripped from the `DetectedAgent` registry projection. They instruct whoever _spawns_
62
+ the CLI, not whoever lists agents — and two carry closures, which `JSON.stringify` would flatten into
63
+ a misleading `{"buffering":"until-close"}` / `{}` rather than omit.
64
+
65
+ In `@jini-ai/daemon`, `AgentExecutor` now stages a log file when `needsAgentLogFile` is set (released
66
+ on every pre-spawn, spawn-failure, and close path alongside the prompt file, through one composed
67
+ `cleanupStagedFiles` closure), calls `runtimeLock.acquire()` before `buildArgs`, and branches its
68
+ `streamFormat: 'plain'` stdout handling on `stdoutPolicy`. `assessAgentExecutorCompatibility` no
69
+ longer rejects anything by id, and `isAgentExecutorSupported` now accepts all 24 definitions.
70
+
71
+ **No behavior change for any other agent.** Antigravity is the only definition declaring any of the
72
+ three fields; the other four `streamFormat: 'plain'` definitions (`grok-build`, `aider`, `deepseek`,
73
+ `qwen`) keep streaming live, per chunk, which `aider`'s and `deepseek`'s own comments call out as
74
+ deliberate. That is pinned by an explicit regression test rather than left implied.
75
+
76
+ One thing worth knowing if you consume the run event stream: on the buffered path the raw `'stdout'`
77
+ echo is held back and sanitized too, not just the `'agent'`/`text_delta` copy. Emitting an
78
+ unsanitized raw echo while withholding the chat copy would leak the exact string the sanitizer exists
79
+ to remove to any subscriber. The opt-in byte journal still records raw bytes per chunk — that is its
80
+ documented contract, and it deliberately lives in a separate `EventLog` instance that is never
81
+ replayed to run-event subscribers.
82
+
83
+ - 3fe4d14: Store tool-result images once in media, preserve model-facing pixels, and provide opt-in digest-verified echo summaries and bounded image-safe diagnostics. Hosts with exact-byte journaling must inject a separate stdout blob/reference writer.
84
+
85
+ Preserve conflicting image representations during digest verification, and keep the required bridge executor authoritative when route dependencies supply optional settings.
86
+
87
+ - e16a907: Choose reported-cost semantics from agent definition metadata rather than a vendor id; absent metadata retains per-run behavior.
88
+ - 4f52784: Add `createRunScopedContextStore` and widen `ResolvedRunInput` with the executor's optional inputs.
89
+
90
+ **`createRunScopedContextStore<T>({ lifecycle })`** — bind/resolve/auto-evict keyed by run id, for
91
+ host-owned context that must survive the gap between starting a run and a later delegated call that
92
+ carries only that run's id. `resolve` fails closed (`RunContextNotBoundError`) rather than fabricating a
93
+ default, and bindings are evicted when the run reaches a terminal state.
94
+
95
+ `resolveRunInput` could already decode and stash such context, but it is handed no lifecycle and so
96
+ cannot register the eviction half — leaving a map that only grows and keeps stale authority resolvable.
97
+ Owning both halves together is the point. `T` is fully generic: this helper does not decode
98
+ `contextRef`, does not sign or verify anything, and has no notion of a principal. It is in-memory only,
99
+ and says so — after a restart every `resolve` fails, which is the correct posture rather than a gap.
100
+
101
+ **`ResolvedRunInput` now carries `permissionMode`, `model`, `reasoning`, and `credentialEnv`**, each
102
+ forwarded to `AgentExecutor.run()` only when present.
103
+
104
+ `permissionMode` is the load-bearing one and closes a real trap: every def with an auto-approve flag
105
+ (`bypassPermissions` / `--yolo` / `--dangerously-skip-permissions`) applies it by default when the field
106
+ is absent. A host that had been passing `'restricted'` to `AgentExecutor.run()` by hand and then adopted
107
+ `createDefaultRunStartHandler` would silently have started auto-approving every action — a security
108
+ regression wearing the clothes of a refactor. Existing behavior is unchanged: omitted fields are omitted
109
+ from the executor call, never passed as an explicit `undefined`.
110
+
111
+ - 4f52784: Add a `sidecar-strict` security mode and per-run MCP credential propagation.
112
+
113
+ For a daemon whose threat model is **another process running as the same OS user** rather than a
114
+ remote attacker, the existing `jini-local` mode is a no-op: `registerApiBearerAuthMiddleware`
115
+ short-circuits for any loopback peer before it reads the `Authorization` header, and a `127.0.0.1`
116
+ bind keeps remote hosts out while doing nothing about a co-resident process. A consumer that spawns a
117
+ Jini daemon holding real authority — starting agent runs, executing tools against a real database —
118
+ previously had to write its own middleware.
119
+
120
+ - `@jini-ai/http-kit` gains `requireStrictBearerToken`: fail-closed 503 when the named token env var
121
+ is unset, 401 on mismatch, **no loopback exemption and no disable flag**. Its `tokenEnvVar` is
122
+ required with no default, so this package never names a host's secret.
123
+ - `composeJiniKernel` gains `security: { mode: 'sidecar-strict', host, tokenEnvVar, exemptPaths? }`.
124
+ Purely additive — `host` and `jini-local` are unchanged by construction, since the modes are arms of
125
+ a discriminated union. The strict gate mounts ahead of the JSON body parser, so a caller it rejects
126
+ never has its body parsed.
127
+ - `@jini-ai/daemon`'s `McpJsonInjectionOptions` gains `credential?: (runId) => string | Promise<string>`
128
+ — a **resolver, not a string**, because injection options are built once before any run exists and a
129
+ boot-wide shared secret would defeat the point of scoping a credential to a run. It is delivered to
130
+ the child as `JINI_DAEMON_TOKEN`.
131
+ - `@jini-ai/mcp`'s `jini-mcp` reads that variable and attaches `Authorization` to every daemon call.
132
+ Optional throughout: with no credential, request headers and `.mcp.json` output are byte-identical
133
+ to before.
134
+
135
+ Also generalized: both existing bearer gates now compare tokens in constant time (`timingSafeEqual`)
136
+ and share one header-parsing helper, closing a timing side channel and removing a duplicated regex.
137
+
138
+ - Updated dependencies [22f0619]
139
+ - Updated dependencies [82ed2d3]
140
+ - Updated dependencies [3fe4d14]
141
+ - Updated dependencies [5a3de86]
142
+ - @jini-ai/core@0.6.10
143
+ - @jini-ai/protocol@0.6.10
144
+
1
145
  ## 0.6.9 — 2026-10-22
2
146
 
3
147
  - 2026-10-22: Memory fixes (bounded event log and tool-executor audit), chromeIntegration option, provider-plugin registration, coverage.
@@ -1,3 +1,5 @@
1
+ import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
2
+ import type { StdoutJournalBlobPort } from '../tool-result-diagnostics.js';
1
3
  import type { MessageAttachmentImage } from '../attachment-content.js';
2
4
  import type { probeMcpBridge } from './mcp-bridge-probe.js';
3
5
  import type { spawn as nodeSpawn, ChildProcess } from 'node:child_process';
@@ -600,6 +602,10 @@ export interface FailureClassificationContext {
600
602
  */
601
603
  export type ClassifyFailure = (context: FailureClassificationContext) => boolean | Promise<boolean>;
602
604
  export interface CreateAgentExecutorOptions {
605
+ /** Negotiated v1 only; share with bridge/lifecycle. Absent preserves legacy diagnostics/echoes. */
606
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
607
+ /** Required with correlation + byte journal: exact stdout blobs and durable reference records. */
608
+ readonly stdoutJournalBlobs?: StdoutJournalBlobPort;
603
609
  /** Host environment snapshot; baseline spawn keys and source config paths are allowlisted. */
604
610
  readonly hostEnv?: NodeJS.ProcessEnv;
605
611
  readonly runtimeState?: AgentRuntimeState;
@@ -101,6 +101,7 @@ function translateToolInputDeltaEvent(rawEvent) {
101
101
  };
102
102
  }
103
103
  export function translateToolResultEvent({ rawEvent }) {
104
+ const delegatedToolUseId = asOptionalString(rawEvent.delegatedToolUseId);
104
105
  const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
105
106
  return {
106
107
  kind: 'agent',
@@ -108,6 +109,7 @@ export function translateToolResultEvent({ rawEvent }) {
108
109
  type: 'tool_result',
109
110
  toolUseId: asString(rawEvent.toolUseId),
110
111
  content: asString(rawEvent.content),
112
+ ...(delegatedToolUseId !== undefined ? { delegatedToolUseId } : {}),
111
113
  ...(isError !== undefined ? { isError } : {}),
112
114
  },
113
115
  };
@@ -1,3 +1,5 @@
1
+ import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
2
+ import type { StdoutJournalBlobPort } from '../tool-result-diagnostics.js';
1
3
  import type { probeMcpBridge } from './mcp-bridge-probe.js';
2
4
  import type { ModelCatalogSnapshot } from '@jini-ai/agent-runtime';
3
5
  import type { ChildProcess } from 'node:child_process';
@@ -24,6 +26,8 @@ export declare function defaultCleanupFailureSink(context: AgentCleanupFailureCo
24
26
  */
25
27
  export declare const DEFAULT_BUFFERED_STDOUT_MAX_BYTES: number;
26
28
  interface WireChildLifecycleContext extends TerminateChildTreeDeps {
29
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
30
+ readonly stdoutJournalBlobs?: StdoutJournalBlobPort;
27
31
  readonly startingModel?: string;
28
32
  readonly modelCatalog?: ModelCatalogSnapshot;
29
33
  readonly runId: string;
@@ -1,3 +1,4 @@
1
+ import { createToolResultDiagnosticProjection, projectDiagnosticFrame } from '../tool-result-diagnostics.js';
1
2
  import { appendSerialTask } from "@jini-ai/core/primitives";
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { createModelReceiptTracker } from './model-receipts.js';
@@ -9,7 +10,7 @@ import { collectProcessTreePids, listProcessSnapshots, stopProcesses, } from '@j
9
10
  import { classifyRunCloseStatus, } from '../close-status.js';
10
11
  import { resolveContinuationTransport, } from '../continuation/continuation-transport.js';
11
12
  import { resultContent, } from '../delegated-tool-bridge.js';
12
- import { extractResultMedia, } from '../tool-result-media.js';
13
+ import { projectToolResultEvent, } from '../tool-result-media.js';
13
14
  import { translateAgentRuntimeEvent, translateAcpError, applyAgentTranslationSideEffects, } from './event-translation.js';
14
15
  import { errorMessage, isRecord, } from './values.js';
15
16
  import { MCP_BRIDGE_UNAVAILABLE, unavailableJiniBridgeStatus, bridgeUnavailableMessage, } from './mcp-bridge.js';
@@ -202,6 +203,35 @@ export const DEFAULT_BUFFERED_STDOUT_MAX_BYTES = 8 * 1024 * 1024;
202
203
  function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
203
204
  return `\n[jini] agent stdout truncated: ${droppedBytes} byte(s) dropped after the ${maxBytes}-byte buffer limit was reached.\n`;
204
205
  }
206
+ /** Keeps exact-byte auditing separate from retained diagnostics on the run's existing FIFO.
207
+ * Projection retains at most one bounded frame; ordinary stdout keeps its legacy byte journal. */
208
+ function createStdoutDiagnostics({ ctx, enqueueEmit }, _options = {}) {
209
+ const { runId, lifecycle, journal, stdoutJournalBlobs } = ctx;
210
+ const projection = ctx.delegatedResultCorrelation && ctx.streamFormat === 'claude-stream-json'
211
+ ? createToolResultDiagnosticProjection({}) : undefined;
212
+ function emit({ text }) {
213
+ if (text)
214
+ enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'stdout', data: { chunk: text } } }));
215
+ }
216
+ return {
217
+ record({ chunk, text }) {
218
+ if (!journal)
219
+ return;
220
+ if (projection && stdoutJournalBlobs) {
221
+ enqueueEmit(() => stdoutJournalBlobs.record({ runId, chunk }));
222
+ }
223
+ else {
224
+ enqueueEmit(() => journal.record({ runId, entry: receivedJournalEntry('stdout', text) }));
225
+ }
226
+ },
227
+ feed({ text }) {
228
+ emit({ text: projection ? projection.feed({ chunk: text }) : text });
229
+ },
230
+ flush() {
231
+ emit({ text: projection?.flush({}) ?? '' });
232
+ },
233
+ };
234
+ }
205
235
  /**
206
236
  * Wires one spawned child's full observable lifecycle: raw stdout/stderr
207
237
  * forwarding, structured stream-parser dispatch (translated via
@@ -264,6 +294,7 @@ function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
264
294
  */
265
295
  export function wireChildLifecycle(ctx) {
266
296
  const { runId, def, streamFormat, child, lifecycle, journal, continuation, classifyFailure } = ctx;
297
+ const stdoutDiagnostics = createStdoutDiagnostics({ ctx, enqueueEmit });
267
298
  const output = observeRunOutput({ lifecycle, runId, agentName: def.name }, { plainStdout: streamFormat === 'plain' });
268
299
  const modelReceipt = createModelReceiptTracker(ctx.startingModel, ctx.modelCatalog?.models);
269
300
  let stdinClosed = false;
@@ -478,11 +509,8 @@ export function wireChildLifecycle(ctx) {
478
509
  const run = { id: runId };
479
510
  let content;
480
511
  let isError;
481
- // Same extraction `delegated-tool-bridge.ts`'s `execute()` runs, kept consistent per
482
- // `resultContent`'s own doc ("both callers share one mapping"). This path never ran
483
- // `splitToolResultSurfaces` (it has no `mcp-ui` withhold-from-model concept — the flattened
484
- // `content` below already carries the whole raw output, a pre-existing, unrelated gap), so
485
- // there is no `remainder` to thread back in — only the extracted blocks are used here.
512
+ // Event projection removes pixels only from saved content; stdin keeps the original.
513
+ let eventContent;
486
514
  let media = [];
487
515
  // Suspend the slow-run watchdog for the duration of this daemon-awaited execution: the daemon
488
516
  // knows exactly why the run is quiet here (it dispatched the tool itself and is waiting on it),
@@ -494,11 +522,14 @@ export function wireChildLifecycle(ctx) {
494
522
  try {
495
523
  const result = await continuation.toolExecutor.execute({ principal: continuation.principal, run: run, toolId: toolUse.name, input: toolUse.input });
496
524
  content = resultContent({ result: result });
497
- isError = result.status !== 'completed';
498
- media = extractResultMedia({ output: result.output }).media;
525
+ const projection = projectToolResultEvent({ result });
526
+ eventContent = projection.content;
527
+ isError = projection.isError === true;
528
+ media = projection.media ?? [];
499
529
  }
500
530
  catch (error) {
501
531
  content = errorMessage(error);
532
+ eventContent = content;
502
533
  isError = true;
503
534
  }
504
535
  finally {
@@ -509,7 +540,7 @@ export function wireChildLifecycle(ctx) {
509
540
  data: {
510
541
  type: 'tool_result',
511
542
  toolUseId: toolUse.id,
512
- content,
543
+ content: eventContent,
513
544
  ...(isError ? { isError: true } : {}),
514
545
  ...(media.length > 0 ? { media } : {}),
515
546
  },
@@ -539,7 +570,10 @@ export function wireChildLifecycle(ctx) {
539
570
  } });
540
571
  if (rejectUnavailableBridge({ payload: translation.payload, rawEvent }, {}))
541
572
  return;
542
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: statusWithChildIdentity({ payload: translation.payload, childPid: ctx.child.pid }, {}) } }));
573
+ enqueueEmit(() => {
574
+ const payload = projectEcho({ payload: translation.payload, rawEvent }, {});
575
+ return lifecycle.emit({ runId, input: { event: 'agent', data: statusWithChildIdentity({ payload, childPid: ctx.child.pid }, {}) } });
576
+ });
543
577
  }
544
578
  else if (translation.kind === 'error') {
545
579
  enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
@@ -548,6 +582,17 @@ export function wireChildLifecycle(ctx) {
548
582
  handleTurnEnd(translation.stopReason);
549
583
  }
550
584
  });
585
+ /** Verify against an already-saved call in this run; unverified metadata preserves all output. */
586
+ function projectEcho({ payload, rawEvent }, _options = {}) {
587
+ if (!ctx.delegatedResultCorrelation)
588
+ return payload;
589
+ if (payload.type === 'raw')
590
+ return { ...payload, line: projectDiagnosticFrame({ line: payload.line }) };
591
+ if (payload.type !== 'tool_result' || !payload.delegatedToolUseId || !Array.isArray(rawEvent.delegatedResultContent))
592
+ return payload;
593
+ const matches = ctx.delegatedResultCorrelation.matches({ runId, toolUseId: payload.delegatedToolUseId, content: rawEvent.delegatedResultContent, isError: payload.isError === true });
594
+ return matches ? { ...payload, content: `Result shown by delegated call ${payload.delegatedToolUseId}.` } : payload;
595
+ }
551
596
  function rejectUnavailableBridge({ payload, rawEvent }, _options = {}) {
552
597
  if (!bridgeChecked && payload.type === 'status' && payload.label === 'initializing') {
553
598
  bridgeChecked = true;
@@ -616,8 +661,7 @@ export function wireChildLifecycle(ctx) {
616
661
  }
617
662
  child.stdout?.on('data', (chunk) => {
618
663
  const text = chunk.toString('utf8');
619
- if (journal)
620
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
664
+ stdoutDiagnostics.record({ chunk, text });
621
665
  if (streamFormat === 'plain') {
622
666
  if (text.length > 0)
623
667
  userVisibleOutputSeen = true;
@@ -643,7 +687,7 @@ export function wireChildLifecycle(ctx) {
643
687
  enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
644
688
  return;
645
689
  }
646
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
690
+ stdoutDiagnostics.feed({ text });
647
691
  // Before the parser: an ack and the aborted turn's end can share a chunk, and the turn end must
648
692
  // see that no interrupt is still waiting (see `stdinHoldAfterTurnEnd`); a background task
649
693
  // starting and the turn's end can share one too.
@@ -686,6 +730,7 @@ export function wireChildLifecycle(ctx) {
686
730
  // driver dispatches to. `streamHandler` is null for `'plain'` (no
687
731
  // parser, hence nothing to flush) — `?.` skips it cleanly.
688
732
  streamHandler?.flush();
733
+ stdoutDiagnostics.flush();
689
734
  // Queued before `await emitQueue` so the flushed text is durably appended ahead of
690
735
  // `finish()`'s `'end'` event, exactly like every live-path emit already is.
691
736
  flushBufferedStdout();
@@ -381,6 +381,6 @@ export declare function confirmChildSpawned({ input, deps }: {
381
381
  * @complexity `run()`'s own setup is O(1); steady-state cost is the chosen stream parser's.
382
382
  * @overallScore 100/100
383
383
  */
384
- export declare function createAgentExecutor(requiredArgs: Pick<CreateAgentExecutorOptions, "lifecycle">, optionalArgs?: Pick<CreateAgentExecutorOptions, "runtimeState" | "codexDisablePlugins" | "piGracefulShutdownMs" | "hostEnv" | "getAgentDef" | "resolveAgentLaunch" | "resolveModelForLaunch" | "ensureAgentCapabilities" | "applyAgentLaunchEnv" | "createCommandInvocation" | "spawn" | "probeMcpBridge" | "attachAcpSession" | "acpPermissionHandler" | "attachPiRpcSession" | "preparePromptFileForAgent" | "prepareAgentLogFile" | "listProcessSnapshots" | "collectProcessTreePids" | "stopProcesses" | "onCleanupFailure" | "journal" | "continuation" | "classifyFailure" | "mcpJsonInjection" | "claudeConfigDirIsolation" | "claudeConfigDirIsolationEnabled" | "bufferedStdoutMaxBytes" | "promptAugmenter">): AgentExecutor;
384
+ export declare function createAgentExecutor(requiredArgs: Pick<CreateAgentExecutorOptions, "lifecycle">, optionalArgs?: Pick<CreateAgentExecutorOptions, "runtimeState" | "codexDisablePlugins" | "piGracefulShutdownMs" | "hostEnv" | "getAgentDef" | "resolveAgentLaunch" | "resolveModelForLaunch" | "ensureAgentCapabilities" | "applyAgentLaunchEnv" | "createCommandInvocation" | "spawn" | "probeMcpBridge" | "attachAcpSession" | "acpPermissionHandler" | "attachPiRpcSession" | "preparePromptFileForAgent" | "prepareAgentLogFile" | "listProcessSnapshots" | "collectProcessTreePids" | "stopProcesses" | "onCleanupFailure" | "journal" | "delegatedResultCorrelation" | "stdoutJournalBlobs" | "continuation" | "classifyFailure" | "mcpJsonInjection" | "claudeConfigDirIsolation" | "claudeConfigDirIsolationEnabled" | "bufferedStdoutMaxBytes" | "promptAugmenter">): AgentExecutor;
385
385
  export {};
386
386
  //# sourceMappingURL=launch.d.ts.map
@@ -616,6 +616,12 @@ export async function confirmChildSpawned({ input, deps }) {
616
616
  function nativeCapabilityInput({ input }, _options = {}) {
617
617
  return { ...(input.nativeCapabilities !== undefined ? { nativeCapabilities: input.nativeCapabilities } : {}) };
618
618
  }
619
+ /** Correlation projects retained stdout, so exact-byte journals need a separate blob writer. */
620
+ function validateStdoutJournalOptions(options, _options = {}) {
621
+ if (options.delegatedResultCorrelation && options.journal && !options.stdoutJournalBlobs) {
622
+ throw new Error('delegated result correlation with a byte journal requires stdoutJournalBlobs');
623
+ }
624
+ }
619
625
  /** Model discovery consumes the exact spawn environment and caller-supplied settings. */
620
626
  function launchModelContext({ input, launch, spawnEnv }, _options = {}) {
621
627
  return {
@@ -664,6 +670,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
664
670
  const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, ensureAgentCapabilities: ensureAgentCapabilitiesFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps({ ...options, hostEnv });
665
671
  const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
666
672
  const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
673
+ validateStdoutJournalOptions(options);
667
674
  const journal = options.journal;
668
675
  const continuation = options.continuation;
669
676
  const classifyFailure = options.classifyFailure;
@@ -811,6 +818,8 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
811
818
  onCleanupFailure: onCleanupFailureFn,
812
819
  cleanupStagedFiles,
813
820
  journal,
821
+ ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}),
822
+ ...(options.stdoutJournalBlobs ? { stdoutJournalBlobs: options.stdoutJournalBlobs } : {}),
814
823
  continuation,
815
824
  classifyFailure,
816
825
  bufferedStdoutMaxBytes,
@@ -0,0 +1,26 @@
1
+ import type { CredentialCryptoPort } from './run-credentials.js';
2
+ interface ResultIdentity {
3
+ readonly runId: string;
4
+ readonly toolUseId: string;
5
+ readonly content: readonly unknown[];
6
+ readonly isError: boolean;
7
+ }
8
+ /** Run-scoped proof of a saved bridge result. Implementations retain digests, never pixels. */
9
+ export interface DelegatedResultCorrelation {
10
+ record(input: ResultIdentity, options?: Record<string, never>): void;
11
+ matches(input: ResultIdentity, options?: Record<string, never>): boolean;
12
+ clearRun(input: {
13
+ runId: string;
14
+ }, options?: Record<string, never>): void;
15
+ }
16
+ /** Bounded digest store. Eviction only disables summarization, so legacy output remains safe.
17
+ * Share this instance with bridge/executor/lifecycle; lifecycle eviction clears each run.
18
+ * @complexity O(n) hashing plus JSON key sorting; O(r*c) retained fixed-size digests. */
19
+ export declare function createDelegatedResultCorrelation({ crypto }: {
20
+ crypto: Pick<CredentialCryptoPort, 'digest'>;
21
+ }, { maxRuns, maxCallsPerRun }?: {
22
+ maxRuns?: number;
23
+ maxCallsPerRun?: number;
24
+ }): DelegatedResultCorrelation;
25
+ export {};
26
+ //# sourceMappingURL=delegated-result-correlation.d.ts.map
@@ -0,0 +1,68 @@
1
+ /** Accept only the complete base64 source shape; extra fields must remain part of the digest. */
2
+ function base64ImageSource({ source }, _options = {}) {
3
+ if (typeof source !== 'object' || source === null)
4
+ return undefined;
5
+ const image = source;
6
+ if (image.type !== 'base64' || typeof image.media_type !== 'string' || typeof image.data !== 'string' || Object.keys(image).length !== 3)
7
+ return undefined;
8
+ return { data: image.data, media_type: image.media_type };
9
+ }
10
+ /** Convert only source-only images; dual representations and extra source fields stay in the proof. */
11
+ function normalizeSourceImage({ record }, _options = {}) {
12
+ if (record.type !== 'image' || 'data' in record || 'mimeType' in record)
13
+ return record;
14
+ const image = base64ImageSource({ source: record.source });
15
+ if (!image)
16
+ return record;
17
+ const { source: _source, ...rest } = record;
18
+ return { ...rest, data: image.data, mimeType: image.media_type };
19
+ }
20
+ /** Normalize unambiguous CLI images and JSON object key order.
21
+ * @complexity O(n + k log k) for n bytes and k keys per object; inputs are wire JSON. */
22
+ function normalizedJson(_key, value) {
23
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
24
+ return value;
25
+ const record = normalizeSourceImage({ record: value });
26
+ return Object.fromEntries(Object.keys(record).sort().map(key => [key, record[key]]));
27
+ }
28
+ /** Bounded digest store. Eviction only disables summarization, so legacy output remains safe.
29
+ * Share this instance with bridge/executor/lifecycle; lifecycle eviction clears each run.
30
+ * @complexity O(n) hashing plus JSON key sorting; O(r*c) retained fixed-size digests. */
31
+ export function createDelegatedResultCorrelation({ crypto }, { maxRuns = 256, maxCallsPerRun = 2048 } = {}) {
32
+ if (!Number.isSafeInteger(maxRuns) || maxRuns < 1 || !Number.isSafeInteger(maxCallsPerRun) || maxCallsPerRun < 1)
33
+ throw new Error('correlation limits must be positive integers');
34
+ const runs = new Map();
35
+ // Optional correlation must not turn an otherwise serializable legacy fallback into a failure
36
+ // (for example, a cyclic in-process tool result handled by serializeDelegatedToolOutput).
37
+ const digest = (input) => {
38
+ try {
39
+ return crypto.digest({ token: JSON.stringify({ isError: input.isError, content: input.content }, normalizedJson) });
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ };
45
+ return {
46
+ record(input) {
47
+ const fingerprint = digest(input);
48
+ if (fingerprint === undefined)
49
+ return;
50
+ let calls = runs.get(input.runId);
51
+ if (!calls) {
52
+ if (runs.size >= maxRuns)
53
+ runs.delete(runs.keys().next().value);
54
+ calls = new Map();
55
+ runs.set(input.runId, calls);
56
+ }
57
+ if (calls.size >= maxCallsPerRun)
58
+ calls.delete(calls.keys().next().value);
59
+ calls.set(input.toolUseId, fingerprint);
60
+ },
61
+ matches(input) {
62
+ const expected = runs.get(input.runId)?.get(input.toolUseId);
63
+ return expected !== undefined && expected === digest(input);
64
+ },
65
+ clearRun({ runId }) { runs.delete(runId); },
66
+ };
67
+ }
68
+ //# sourceMappingURL=delegated-result-correlation.js.map
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { type Principal } from '@jini-ai/core';
10
10
  import type { RunLifecycle } from './run-lifecycle.js';
11
+ import type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
11
12
  import type { ToolExecutionResult, ToolExecutor } from './tool-executor.js';
12
13
  /**
13
14
  * Run-protocol `type` for a human-only surface withheld from a tool result.
@@ -42,24 +43,15 @@ export interface DelegatedToolBridge {
42
43
  export interface CreateDelegatedToolBridgeOptions {
43
44
  readonly lifecycle: RunLifecycle;
44
45
  readonly toolExecutor: ToolExecutor;
46
+ /** Share with the executor and lifecycle only after the host negotiates marker v1. */
47
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
45
48
  }
46
- /** Converts arbitrary registered-tool output into the string-bearing run protocol. */
47
- export declare function serializeDelegatedToolOutput({ output }: {
48
- readonly output: unknown;
49
- }): string;
50
- /**
51
- * Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
52
- * gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
53
- * output/failure reason. Exported so both real callers share one mapping rather than drifting.
54
- */
55
- export declare function resultContent({ result }: {
56
- readonly result: ToolExecutionResult;
57
- }): string;
49
+ export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
58
50
  /**
59
51
  * Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
60
52
  * other host protocols. This module does not invent a server transport: a
61
53
  * concrete protocol calls this bridge after it has decoded and authenticated a
62
54
  * delegated tool request.
63
55
  */
64
- export declare function createDelegatedToolBridge(options: CreateDelegatedToolBridgeOptions): DelegatedToolBridge;
56
+ export declare function createDelegatedToolBridge(requiredArgs: Pick<CreateDelegatedToolBridgeOptions, 'lifecycle' | 'toolExecutor'>, optionalArgs?: Pick<CreateDelegatedToolBridgeOptions, 'delegatedResultCorrelation'>): DelegatedToolBridge;
65
57
  //# sourceMappingURL=delegated-tool-bridge.d.ts.map
@@ -6,8 +6,8 @@
6
6
  * enforces Jini's registry policy, confirmation, timeout, cancellation, and
7
7
  * audit trail before any registered handler runs.
8
8
  */
9
- import { batchOutcomeFor } from '@jini-ai/core';
10
- import { extractResultMedia } from './tool-result-media.js';
9
+ import {} from '@jini-ai/core';
10
+ import { projectToolResultEvent } from './tool-result-media.js';
11
11
  import { splitToolResultSurfaces } from './tool-result-surfaces.js';
12
12
  /**
13
13
  * Run-protocol `type` for a human-only surface withheld from a tool result.
@@ -38,41 +38,7 @@ function correlationFor(channel, toolUseId) {
38
38
  function errorMessage(error) {
39
39
  return error instanceof Error ? error.message : String(error);
40
40
  }
41
- /** Converts arbitrary registered-tool output into the string-bearing run protocol. */
42
- export function serializeDelegatedToolOutput({ output }) {
43
- if (typeof output === 'string')
44
- return output;
45
- if (output === undefined)
46
- return '';
47
- try {
48
- const serialized = JSON.stringify(output);
49
- return serialized === undefined ? String(output) : serialized;
50
- }
51
- catch {
52
- return String(output);
53
- }
54
- }
55
- /**
56
- * Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
57
- * gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
58
- * output/failure reason. Exported so both real callers share one mapping rather than drifting.
59
- */
60
- export function resultContent({ result }) {
61
- switch (result.status) {
62
- case 'completed':
63
- return serializeDelegatedToolOutput({ output: result.output });
64
- case 'denied':
65
- return 'Tool execution denied by policy.';
66
- case 'confirmation-denied':
67
- return 'Tool execution denied during confirmation.';
68
- case 'timed-out':
69
- return 'Tool execution timed out.';
70
- case 'cancelled':
71
- return 'Tool execution cancelled.';
72
- case 'failed':
73
- return result.error ?? 'Tool execution failed.';
74
- }
75
- }
41
+ export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
76
42
  /**
77
43
  * Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
78
44
  * invocation's own transport signal. `dispose()` detaches both listeners once the call settles.
@@ -168,21 +134,7 @@ function createSurfaceSeam({ lifecycle, runId, toolUseId }) {
168
134
  return { emitSurface, settle, release };
169
135
  }
170
136
  /** Splits human-only surfaces out of an executed result, emits them, then the matching `tool_result`. */
171
- async function emitExecutedResult({ lifecycle, runId, toolUseId }, { executed }) {
172
- // Recognized media blocks (images) are collected here purely to populate the sibling
173
- // `media` field the `tool_result` event carries below, for a UI that wants to render one
174
- // inline without re-parsing `content` (`ToolCard`'s `<img>`, per `assistant_demo_image`'s
175
- // own doc). This does NOT remove the image block from `executed.output` — `image` is one
176
- // of `tool-result-surfaces.ts`'s own `MODEL_VISIBLE_BLOCK_TYPES`, so the split below already
177
- // keeps it in `modelOutput` on its own. An image reaching the model IS the point
178
- // (`assistant_demo_image`/`admin.capture_screenshot` exist to prove exactly that round
179
- // trip) — unlike an `mcp-ui` resource, which is genuinely human-only and must never survive
180
- // into `modelOutput`. (Earlier this extraction fed its own de-imaged `remainder` into the
181
- // surfaces split below and discarded the image from `output` entirely, on the assumption
182
- // that the sibling `media` field was the only channel a caller needed — true for a UI
183
- // reading the run's event stream, false for `@jini-ai/mcp`'s `execute_delegated_tool`, which
184
- // hands the bridge's RETURN VALUE to the model with no access to that event stream at all.)
185
- const { media } = extractResultMedia({ output: executed.output });
137
+ async function emitExecutedResult({ lifecycle, runId, toolUseId, delegatedResultCorrelation }, { executed }) {
186
138
  // THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
187
139
  // receives, so a UI resource left inside it is model-visible context no matter what any
188
140
  // downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
@@ -202,32 +154,41 @@ async function emitExecutedResult({ lifecycle, runId, toolUseId }, { executed })
202
154
  data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
203
155
  } });
204
156
  }
157
+ // The return keeps model-visible images; only the stored event removes their duplicate.
158
+ const projection = projectToolResultEvent({ result });
205
159
  await lifecycle.emit({ runId: runId, input: {
206
160
  event: 'agent',
207
161
  data: {
208
162
  type: 'tool_result',
209
163
  toolUseId,
210
- content: resultContent({ result }),
211
- ...(result.status === 'completed' && (batchOutcomeFor({ output: result.output }) ?? 'completed') === 'completed' ? {} : { isError: true }),
212
- ...(media.length > 0 ? { media } : {}),
164
+ ...projection,
213
165
  },
214
166
  } });
167
+ // Record only after the canonical result is saved; failed emission cannot hide an echo.
168
+ if (result.status === 'completed' && isContentEnvelope(result.output)) {
169
+ delegatedResultCorrelation?.record({ runId, toolUseId, content: result.output.content, isError: projection.isError === true });
170
+ }
215
171
  return result;
216
172
  }
173
+ function isContentEnvelope(output) {
174
+ return typeof output === 'object' && output !== null && 'content' in output && Array.isArray(output.content);
175
+ }
217
176
  /**
218
177
  * Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
219
178
  * other host protocols. This module does not invent a server transport: a
220
179
  * concrete protocol calls this bridge after it has decoded and authenticated a
221
180
  * delegated tool request.
222
181
  */
223
- export function createDelegatedToolBridge(options) {
224
- const { lifecycle, toolExecutor } = options;
182
+ export function createDelegatedToolBridge(requiredArgs, optionalArgs = {}) {
183
+ const options = { ...requiredArgs, ...optionalArgs };
184
+ // A route may pass its broader deps as options; its wrapped executor remains authoritative.
185
+ const { lifecycle, toolExecutor } = requiredArgs;
225
186
  async function execute(requiredArgs, optionalArgs = {}) {
226
187
  // Merged rather than read from `optionalArgs` alone: a caller may hand `signal` in the first
227
188
  // object (the delegated-tools HTTP route does), and it must still be honoured.
228
189
  const invocation = { ...requiredArgs, ...optionalArgs };
229
190
  const { runId, toolUseId, toolId, principal, input } = invocation;
230
- const call = { lifecycle, runId, toolUseId };
191
+ const call = { lifecycle, runId, toolUseId, ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}) };
231
192
  await lifecycle.emit({ runId: runId, input: {
232
193
  event: 'agent',
233
194
  data: { type: 'tool_use', id: toolUseId, name: toolId, input },
@@ -1,3 +1,4 @@
1
+ import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
1
2
  import type { Express } from 'express';
2
3
  import { type Principal, type ToolRegistry } from '@jini-ai/core';
3
4
  import { type RunLifecycle, type ToolExecutionResult, type ToolExecutor } from '../index.js';
@@ -49,6 +50,8 @@ export interface DelegatedToolsInternalErrorContext {
49
50
  readonly status?: 'timed-out' | 'cancelled' | 'failed';
50
51
  }
51
52
  export interface DelegatedToolsHttpDeps {
53
+ /** The same negotiated digest store supplied to the run lifecycle and agent executor. */
54
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
52
55
  readonly lifecycle: RunLifecycle;
53
56
  readonly toolExecutor: ToolExecutor;
54
57
  /** Host-owned: resolves the `Principal` a given delegated-tool-call request executes as. Mandatory — see module doc; there is no safe default identity this package could assume on a host's behalf. */
@@ -203,7 +203,7 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
203
203
  const toolExecutor = input.requireReadOnly === true
204
204
  ? readOnlyTools.withReadOnlyToolConstraint({ inner: deps.toolExecutor, registry: deps.toolRegistry, messages: readOnlyTools.defaultDaemonMessages.readOnly, idGenerator: { newId: randomUUID } })
205
205
  : deps.toolExecutor;
206
- const bridge = createDelegatedToolBridge({ lifecycle: deps.lifecycle, toolExecutor });
206
+ const bridge = createDelegatedToolBridge({ lifecycle: deps.lifecycle, toolExecutor }, deps);
207
207
  try {
208
208
  // `signal` carries the caller's HTTP connection dropping — see `mountJsonRoute`
209
209
  // (`adapter.ts`). The bridge already combines it with the run's own cancellation
package/dist/index.d.ts CHANGED
@@ -93,4 +93,6 @@ export * from './run/index.js';
93
93
  export * from './continuation/index.js';
94
94
  export * from './routines/index.js';
95
95
  export * from './attachment-content.js';
96
+ export * from './delegated-result-correlation.js';
97
+ export type { StdoutJournalBlobPort } from './tool-result-diagnostics.js';
96
98
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -93,4 +93,5 @@ export * from './run/index.js';
93
93
  export * from './continuation/index.js';
94
94
  export * from './routines/index.js';
95
95
  export * from './attachment-content.js';
96
+ export * from './delegated-result-correlation.js';
96
97
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,20 @@
1
+ /**
2
+ * `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
3
+ * `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
4
+ * never a product-record identifier), generalized from OD's `design.runs` service
5
+ * (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
6
+ * `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
7
+ * branch — see `archived provenance ledger` for exact line citations).
8
+ *
9
+ * Scope boundary: this module owns the run *state machine* and its event
10
+ * log — create, transition, emit, replay, cancellation-intent propagation,
11
+ * idempotent start/finish, resume. It does not spawn or signal a subprocess;
12
+ * that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
13
+ * (today: this package's tests; later: agent-runtime) calls `emit()` for
14
+ * agent/stdout/stderr/error events, observes cancellation via
15
+ * `onCancelRequested`, and calls `finish()` once it knows the real outcome.
16
+ */
17
+ import type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
1
18
  import type { RunAgentPayload, RunCancelRequest, RunChunkPayload, RunErrorPayload, RunProtocolEvent, RunStartPayload, RunStatus } from '@jini-ai/protocol';
2
19
  import type { EventLog, EventLogEntry, EventLogReplayResult } from './event-log.js';
3
20
  import type { InactivityWatchdog, TerminalRunOutcome } from './close-status.js';
@@ -276,6 +293,8 @@ export declare function finishStreamSubscription({ record, subscriber, terminal
276
293
  readonly terminal: boolean;
277
294
  }): StreamSubscribeResult;
278
295
  export interface CreateRunLifecycleInput {
296
+ /** Share the negotiated bridge/executor digest store so run eviction also releases proofs. */
297
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
279
298
  readonly eventLog: EventLog;
280
299
  /** Host-owned sink for asynchronous lifecycle failures that cannot be returned to a caller. Defaults to `console.error`. */
281
300
  readonly onInternalError?: (context: RunLifecycleInternalErrorContext) => void;
@@ -319,5 +338,5 @@ export interface RunLifecycleInternalErrorContext {
319
338
  * @complexity Per-call complexities documented on each method; the registry itself is a `Map` keyed by `runId` (O(1) lookup). Memory is bounded: non-terminal runs are O(n) in concurrently-live runs, and terminal runs are capped at `maxTerminalRuns` (each additionally bounded in retention time by `terminalRetentionMs`) rather than growing for the life of the process.
320
339
  * @overallScore 100/100
321
340
  */
322
- export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction">): RunLifecycle;
341
+ export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction" | "delegatedResultCorrelation">): RunLifecycle;
323
342
  //# sourceMappingURL=run-lifecycle.d.ts.map
@@ -1,19 +1,3 @@
1
- /**
2
- * `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
3
- * `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
4
- * never a product-record identifier), generalized from OD's `design.runs` service
5
- * (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
6
- * `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
7
- * branch — see `archived provenance ledger` for exact line citations).
8
- *
9
- * Scope boundary: this module owns the run *state machine* and its event
10
- * log — create, transition, emit, replay, cancellation-intent propagation,
11
- * idempotent start/finish, resume. It does not spawn or signal a subprocess;
12
- * that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
13
- * (today: this package's tests; later: agent-runtime) calls `emit()` for
14
- * agent/stdout/stderr/error events, observes cancellation via
15
- * `onCancelRequested`, and calls `finish()` once it knows the real outcome.
16
- */
17
1
  import { randomUUID } from 'node:crypto';
18
2
  import { RUN_PROTOCOL_VERSION, isTerminalRunState } from '@jini-ai/protocol';
19
3
  import { createInactivityWatchdog } from './close-status.js';
@@ -367,6 +351,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
367
351
  }
368
352
  catch (error) {
369
353
  runs.delete(runId);
354
+ input.delegatedResultCorrelation?.clearRun({ runId });
370
355
  clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: startInput.idempotencyKey, runId: runId });
371
356
  throw error;
372
357
  }
@@ -418,6 +403,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
418
403
  return;
419
404
  record.retentionTimer = undefined;
420
405
  runs.delete(runId);
406
+ input.delegatedResultCorrelation?.clearRun({ runId });
421
407
  clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: record.idempotencyKey, runId: runId });
422
408
  if (input.dropEventsOnEviction === true)
423
409
  void dropEvictedRunEvents(runId);
@@ -0,0 +1,27 @@
1
+ /** Audit writers must store exact chunks in blobs and retain resolvable references separately.
2
+ * This port deliberately cannot masquerade as the inline RunByteJournal contract. */
3
+ export interface StdoutJournalBlobPort {
4
+ /** Buffer chunks remain bytes, including UTF-8 characters split at OS read boundaries. */
5
+ record(input: {
6
+ runId: string;
7
+ chunk: string | Uint8Array;
8
+ }, options?: Record<string, never>): Promise<void>;
9
+ }
10
+ /** Projects one complete diagnostic frame; ordinary non-JSON output remains byte-identical. */
11
+ export declare function projectDiagnosticFrame({ line }: {
12
+ line: string;
13
+ }, { maxFrameChars }?: {
14
+ maxFrameChars?: number;
15
+ }): string;
16
+ /** Bounded JSONL framing: split chunks are held until complete, tails flush at close.
17
+ * Oversized frames produce an explicit notice and are discarded through their newline.
18
+ * @complexity O(n) total input characters; O(maxFrameChars) retained carry, no per-chunk joins. */
19
+ export declare function createToolResultDiagnosticProjection(_input: Record<string, never>, { maxFrameChars }?: {
20
+ maxFrameChars?: number;
21
+ }): {
22
+ feed({ chunk }: {
23
+ chunk: string;
24
+ }, _options?: {}): string;
25
+ flush(_input: Record<string, never>, _options?: {}): string;
26
+ };
27
+ //# sourceMappingURL=tool-result-diagnostics.d.ts.map
@@ -0,0 +1,77 @@
1
+ function imageSummary(value) {
2
+ if (value.type !== 'image')
3
+ return undefined;
4
+ if (typeof value.mimeType === 'string' && typeof value.data === 'string') {
5
+ return { ...value, data: `[image ${value.mimeType}; ${value.data.length} base64 characters]` };
6
+ }
7
+ const source = value.source;
8
+ if (typeof source !== 'object' || source === null)
9
+ return undefined;
10
+ const image = source;
11
+ if (image.type !== 'base64' || typeof image.data !== 'string' || typeof image.media_type !== 'string')
12
+ return undefined;
13
+ return { ...value, source: { ...image, data: `[image ${image.media_type}; ${image.data.length} base64 characters]` } };
14
+ }
15
+ /** Redacts only recognized image fields, including JSON-encoded tool-result content.
16
+ * @complexity O(n) frame bytes, bounded by the framing limit before parsing. */
17
+ function summarizeImages(_key, value) {
18
+ if (_key === 'content' && typeof value === 'string')
19
+ return projectDiagnosticFrame({ line: value });
20
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
21
+ return value;
22
+ return imageSummary(value) ?? value;
23
+ }
24
+ /** Projects one complete diagnostic frame; ordinary non-JSON output remains byte-identical. */
25
+ export function projectDiagnosticFrame({ line }, { maxFrameChars = 16 * 1024 * 1024 } = {}) {
26
+ if (line.length > maxFrameChars)
27
+ return '[diagnostic frame exceeded image projection limit]';
28
+ try {
29
+ return JSON.stringify(JSON.parse(line), summarizeImages);
30
+ }
31
+ catch {
32
+ return line;
33
+ }
34
+ }
35
+ /** Bounded JSONL framing: split chunks are held until complete, tails flush at close.
36
+ * Oversized frames produce an explicit notice and are discarded through their newline.
37
+ * @complexity O(n) total input characters; O(maxFrameChars) retained carry, no per-chunk joins. */
38
+ export function createToolResultDiagnosticProjection(_input, { maxFrameChars = 16 * 1024 * 1024 } = {}) {
39
+ if (!Number.isSafeInteger(maxFrameChars) || maxFrameChars < 1)
40
+ throw new Error('diagnostic frame limit must be a positive integer');
41
+ let parts = [];
42
+ let size = 0;
43
+ let dropping = false;
44
+ function append(part) {
45
+ if (dropping || part.length === 0)
46
+ return;
47
+ size += part.length;
48
+ if (size > maxFrameChars) {
49
+ parts = [];
50
+ dropping = true;
51
+ return;
52
+ }
53
+ parts.push(part);
54
+ }
55
+ function finish() {
56
+ const line = dropping ? '[diagnostic frame exceeded image projection limit]' : projectDiagnosticFrame({ line: parts.join('') }, { maxFrameChars });
57
+ parts = [];
58
+ size = 0;
59
+ dropping = false;
60
+ return line;
61
+ }
62
+ return {
63
+ feed({ chunk }, _options = {}) {
64
+ const completed = [];
65
+ let start = 0;
66
+ for (let end = chunk.indexOf('\n'); end !== -1; end = chunk.indexOf('\n', start)) {
67
+ append(chunk.slice(start, end));
68
+ completed.push(finish(), '\n');
69
+ start = end + 1;
70
+ }
71
+ append(chunk.slice(start));
72
+ return completed.join('');
73
+ },
74
+ flush(_input, _options = {}) { return size > 0 || dropping ? finish() : ''; },
75
+ };
76
+ }
77
+ //# sourceMappingURL=tool-result-diagnostics.js.map
@@ -1,3 +1,4 @@
1
+ import type { ToolExecutionResult } from './tool-executor.js';
1
2
  /**
2
3
  * Extracts UI-renderable media blocks from a tool handler's raw MCP-style content envelope
3
4
  * (`{ content: [...] }`), for `delegated-tool-bridge.ts` and `agent-executor.ts` to attach to the
@@ -11,18 +12,9 @@
11
12
  * and is deliberately narrower: it recognizes exactly one block type today (`image`), and has no
12
13
  * withhold-from-model concept of its own.
13
14
  *
14
- * The two DO interact, though not by pipelining: `delegated-tool-bridge.ts`'s `execute()` calls both
15
- * of them against the SAME raw `executed.output`, not this module's `remainder` into
16
- * `splitToolResultSurfaces`. That used to be pipelined — this module removed recognized image
17
- * blocks from `content` before the surfaces pass ran, because `splitToolResultSurfaces`'s whitelist
18
- * used to recognize only `text`, and an image block left in place would have been swept into
19
- * `surfaces` and withheld from the model. Now that `image` is itself one of
20
- * `tool-result-surfaces.ts`'s `MODEL_VISIBLE_BLOCK_TYPES` (a deliberate addition — see that
21
- * constant's own doc), the surfaces pass classifies an image block correctly on its own, so there is
22
- * no misclassification left to prevent by removing it first. This module's `remainder` field is
23
- * therefore no longer read by that caller at all (kept for this module's own tests and any future
24
- * caller that wants a de-imaged copy); only `media` is used, to populate the `tool_result` event's
25
- * sibling `media` field for a UI that wants to render inline without re-parsing `content`.
15
+ * The surface whitelist runs on the original output first. Its model-visible result still
16
+ * contains images for MCP/model delivery. Only the event projection below serializes this
17
+ * module's de-imaged remainder, alongside one `media` array for rendering.
26
18
  *
27
19
  * ## Why only `image`, and why blocks are silently skipped rather than reported
28
20
  *
@@ -67,8 +59,7 @@ export interface ExtractResultMedia {
67
59
  }
68
60
  /**
69
61
  * Splits a tool handler's raw return value into its recognized media blocks and the remainder,
70
- * ready to feed onward into `splitToolResultSurfaces` (see this module's doc for why that order
71
- * matters).
62
+ * without modifying the model-facing output. Run the surface whitelist on the original first.
72
63
  *
73
64
  * @param output - A tool handler's raw return value (`ToolExecutionResult.output` when
74
65
  * `status === 'completed'`). Non-envelope values (the overwhelming majority of tools, which return
@@ -78,4 +69,26 @@ export interface ExtractResultMedia {
78
69
  export declare function extractResultMedia({ output }: {
79
70
  readonly output: unknown;
80
71
  }): ExtractResultMedia;
72
+ /** Converts arbitrary registered-tool output into the string-bearing run protocol. */
73
+ export declare function serializeDelegatedToolOutput({ output }: {
74
+ readonly output: unknown;
75
+ }): string;
76
+ /**
77
+ * Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
78
+ * gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
79
+ * output/failure reason. Exported so both real callers share one mapping rather than drifting.
80
+ */
81
+ export declare function resultContent({ result }: {
82
+ readonly result: ToolExecutionResult;
83
+ }): string;
84
+ /** Projects a model-safe result into the saved event: pixels occur only in `media`.
85
+ * Uses the existing serializer and status mapping; callers keep the original for the model.
86
+ * @complexity O(n) output bytes, with O(n) transient serialization space. */
87
+ export declare function projectToolResultEvent({ result }: {
88
+ result: ToolExecutionResult;
89
+ }, _options?: {}): {
90
+ content: string;
91
+ isError?: true;
92
+ media?: readonly ToolResultMediaBlock[];
93
+ };
81
94
  //# sourceMappingURL=tool-result-media.d.ts.map
@@ -1,39 +1,4 @@
1
- /**
2
- * Extracts UI-renderable media blocks from a tool handler's raw MCP-style content envelope
3
- * (`{ content: [...] }`), for `delegated-tool-bridge.ts` and `agent-executor.ts` to attach to the
4
- * `tool_result` wire event's new `media` field (`@jini-ai/protocol`'s `events.ts`).
5
- *
6
- * ## Why this is not `tool-result-surfaces.ts`
7
- *
8
- * That module's whitelist governs a SECURITY property: which blocks may reach the model versus
9
- * which are withheld for a human-only UI resource (MCP-UI, still deferred). This module answers a
10
- * different question — "does this block carry displayable media a chat pane can render inline" —
11
- * and is deliberately narrower: it recognizes exactly one block type today (`image`), and has no
12
- * withhold-from-model concept of its own.
13
- *
14
- * The two DO interact, though not by pipelining: `delegated-tool-bridge.ts`'s `execute()` calls both
15
- * of them against the SAME raw `executed.output`, not this module's `remainder` into
16
- * `splitToolResultSurfaces`. That used to be pipelined — this module removed recognized image
17
- * blocks from `content` before the surfaces pass ran, because `splitToolResultSurfaces`'s whitelist
18
- * used to recognize only `text`, and an image block left in place would have been swept into
19
- * `surfaces` and withheld from the model. Now that `image` is itself one of
20
- * `tool-result-surfaces.ts`'s `MODEL_VISIBLE_BLOCK_TYPES` (a deliberate addition — see that
21
- * constant's own doc), the surfaces pass classifies an image block correctly on its own, so there is
22
- * no misclassification left to prevent by removing it first. This module's `remainder` field is
23
- * therefore no longer read by that caller at all (kept for this module's own tests and any future
24
- * caller that wants a de-imaged copy); only `media` is used, to populate the `tool_result` event's
25
- * sibling `media` field for a UI that wants to render inline without re-parsing `content`.
26
- *
27
- * ## Why only `image`, and why blocks are silently skipped rather than reported
28
- *
29
- * `text` blocks already reach the model (and the UI, via `content`) through the existing flattened
30
- * string — duplicating them into `media` would be redundant, not additive, so this module leaves
31
- * them in `remainder` and never collects them. A block claiming `type: 'image'` without a string
32
- * `mimeType`/`data` is dropped rather than surfaced as a malformed-block error: a handler's return
33
- * value is untrusted input to this module (it may come from an external MCP server), and the
34
- * contract is "render what is well-formed, ignore what isn't" — the same fail-quiet posture
35
- * `serializeDelegatedToolOutput` already takes on a non-serializable output.
36
- */
1
+ import { batchOutcomeFor } from '@jini-ai/core';
37
2
  function isRecord(value) {
38
3
  return typeof value === 'object' && value !== null && !Array.isArray(value);
39
4
  }
@@ -50,8 +15,7 @@ function asImageBlock(block) {
50
15
  }
51
16
  /**
52
17
  * Splits a tool handler's raw return value into its recognized media blocks and the remainder,
53
- * ready to feed onward into `splitToolResultSurfaces` (see this module's doc for why that order
54
- * matters).
18
+ * without modifying the model-facing output. Run the surface whitelist on the original first.
55
19
  *
56
20
  * @param output - A tool handler's raw return value (`ToolExecutionResult.output` when
57
21
  * `status === 'completed'`). Non-envelope values (the overwhelming majority of tools, which return
@@ -77,4 +41,53 @@ export function extractResultMedia({ output }) {
77
41
  return { remainder: output, media: [] };
78
42
  return { remainder: { ...output, content: kept }, media };
79
43
  }
44
+ /** Converts arbitrary registered-tool output into the string-bearing run protocol. */
45
+ export function serializeDelegatedToolOutput({ output }) {
46
+ if (typeof output === 'string')
47
+ return output;
48
+ if (output === undefined)
49
+ return '';
50
+ try {
51
+ const serialized = JSON.stringify(output);
52
+ return serialized === undefined ? String(output) : serialized;
53
+ }
54
+ catch {
55
+ return String(output);
56
+ }
57
+ }
58
+ /**
59
+ * Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
60
+ * gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
61
+ * output/failure reason. Exported so both real callers share one mapping rather than drifting.
62
+ */
63
+ export function resultContent({ result }) {
64
+ switch (result.status) {
65
+ case 'completed':
66
+ return serializeDelegatedToolOutput({ output: result.output });
67
+ case 'denied':
68
+ return 'Tool execution denied by policy.';
69
+ case 'confirmation-denied':
70
+ return 'Tool execution denied during confirmation.';
71
+ case 'timed-out':
72
+ return 'Tool execution timed out.';
73
+ case 'cancelled':
74
+ return 'Tool execution cancelled.';
75
+ case 'failed':
76
+ return result.error ?? 'Tool execution failed.';
77
+ }
78
+ }
79
+ /** Projects a model-safe result into the saved event: pixels occur only in `media`.
80
+ * Uses the existing serializer and status mapping; callers keep the original for the model.
81
+ * @complexity O(n) output bytes, with O(n) transient serialization space. */
82
+ export function projectToolResultEvent({ result }, _options = {}) {
83
+ const { remainder, media } = extractResultMedia({ output: result.output });
84
+ const isError = result.status !== 'completed'
85
+ || (batchOutcomeFor({ output: result.output }) ?? 'completed') !== 'completed'
86
+ || (isRecord(result.output) && result.output.isError === true);
87
+ return {
88
+ content: resultContent({ result: { ...result, output: remainder } }),
89
+ ...(isError ? { isError: true } : {}),
90
+ ...(media.length > 0 ? { media } : {}),
91
+ };
92
+ }
80
93
  //# sourceMappingURL=tool-result-media.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/daemon",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
4
4
  "description": "The stateful agent daemon runtime: run lifecycle, durable event-log port, the tool-execution boundary, agent executor, terminal sessions, and routines.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -139,29 +139,29 @@
139
139
  "@types/pg": "8.23.1",
140
140
  "express": "^4.21.0",
141
141
  "@types/express": "^4.17.21",
142
- "@jini-ai/db": "0.3.6",
143
- "@jini-ai/http-kit": "0.4.3",
144
- "@jini-ai/agent-runtime": "^0.4.12",
145
- "@jini-ai/core": "^0.4.9",
146
- "@jini-ai/protocol": "^0.4.5",
147
- "@jini-ai/platform": "^0.5.10",
148
- "@jini-ai/chat": "0.6.7"
142
+ "@jini-ai/http-kit": "0.6.10",
143
+ "@jini-ai/db": "0.6.10",
144
+ "@jini-ai/chat": "0.6.10",
145
+ "@jini-ai/core": "^0.6.10",
146
+ "@jini-ai/agent-runtime": "^0.6.10",
147
+ "@jini-ai/protocol": "^0.6.10",
148
+ "@jini-ai/platform": "^0.6.10"
149
149
  },
150
150
  "dependencies": {
151
- "@jini-ai/core": "^0.4.9",
152
- "@jini-ai/protocol": "^0.4.5"
151
+ "@jini-ai/protocol": "^0.6.10",
152
+ "@jini-ai/core": "^0.6.10"
153
153
  },
154
154
  "peerDependencies": {
155
155
  "node-pty": "^1.1.0",
156
- "@jini-ai/db": "^0.3.2",
156
+ "@jini-ai/db": "^0.6.10",
157
157
  "kysely": "^0.29.6",
158
158
  "better-sqlite3": "^13.0.0",
159
159
  "@electric-sql/pglite": "0.5.8",
160
160
  "pg": "^8.23.0",
161
- "@jini-ai/http-kit": "^0.4.0",
161
+ "@jini-ai/http-kit": "^0.6.10",
162
162
  "express": "^4.21.0 || ^5.0.0",
163
- "@jini-ai/agent-runtime": "^0.4.10",
164
- "@jini-ai/platform": "^0.5.5"
163
+ "@jini-ai/agent-runtime": "^0.6.10",
164
+ "@jini-ai/platform": "^0.6.10"
165
165
  },
166
166
  "peerDependenciesMeta": {
167
167
  "node-pty": {