@jini-ai/daemon 0.6.8 → 0.6.9

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +15 -0
  18. package/dist/core/read-only-tools.js +29 -1
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-gaps.d.ts +60 -0
  22. package/dist/core/tool-gaps.js +199 -0
  23. package/dist/delegated-tool-bridge.d.ts +1 -1
  24. package/dist/delegated-tool-bridge.js +161 -122
  25. package/dist/event-log.d.ts +45 -2
  26. package/dist/event-log.js +83 -4
  27. package/dist/http/attachments.d.ts +26 -1
  28. package/dist/http/attachments.js +129 -65
  29. package/dist/http/connectors.d.ts +1 -32
  30. package/dist/http/connectors.js +13 -68
  31. package/dist/http/delegated-tools.d.ts +5 -1
  32. package/dist/http/delegated-tools.js +29 -29
  33. package/dist/http/host-tools.d.ts +1 -0
  34. package/dist/http/host-tools.js +64 -59
  35. package/dist/http/index.d.ts +2 -2
  36. package/dist/http/index.js +1 -1
  37. package/dist/http/media.js +48 -69
  38. package/dist/http/memory.js +45 -36
  39. package/dist/http/model-proxy-fields.d.ts +23 -0
  40. package/dist/http/model-proxy-fields.js +29 -0
  41. package/dist/http/model-proxy.js +193 -395
  42. package/dist/http/research-tavily.adapter.js +72 -49
  43. package/dist/http/research.js +33 -23
  44. package/dist/http/resource-request.d.ts +37 -0
  45. package/dist/http/resource-request.js +59 -0
  46. package/dist/http/routines.js +49 -90
  47. package/dist/http/runs.d.ts +7 -5
  48. package/dist/http/runs.js +21 -7
  49. package/dist/http/terminals.js +23 -48
  50. package/dist/http/tool-catalog.d.ts +5 -0
  51. package/dist/http/xai.d.ts +11 -1
  52. package/dist/http/xai.js +125 -124
  53. package/dist/legacy-data-migration.js +41 -24
  54. package/dist/run/core/retry.js +51 -35
  55. package/dist/run/diagnostics/diagnostics.js +5 -17
  56. package/dist/run-credentials.js +49 -27
  57. package/dist/run-lifecycle.d.ts +12 -2
  58. package/dist/run-lifecycle.js +47 -26
  59. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  60. package/dist/store/agent-sessions/sqlite.js +6 -4
  61. package/dist/surface-exchanges.js +2 -1
  62. package/dist/terminal-session.d.ts +1 -1
  63. package/dist/terminal-session.js +26 -33
  64. package/dist/tool-audit.d.ts +1 -0
  65. package/dist/tool-audit.js +1 -0
  66. package/dist/tool-executor.d.ts +24 -3
  67. package/dist/tool-executor.js +276 -139
  68. package/dist/tool-recovery/recovery.js +64 -35
  69. package/dist/tool-recovery/stack.d.ts +2 -1
  70. package/dist/tool-recovery/stack.js +1 -1
  71. package/package.json +10 -10
@@ -1,3 +1,12 @@
1
+ /**
2
+ * `DelegatedToolBridge` — the execution path for agents/protocols that ask
3
+ * Jini to run a registered tool on their behalf. It is deliberately separate
4
+ * from ACP's `session/request_permission`: ACP authorizes an agent's *native*
5
+ * tool loop, whereas this bridge invokes Jini's `ToolExecutor` and therefore
6
+ * enforces Jini's registry policy, confirmation, timeout, cancellation, and
7
+ * audit trail before any registered handler runs.
8
+ */
9
+ import { batchOutcomeFor } from '@jini-ai/core';
1
10
  import { extractResultMedia } from './tool-result-media.js';
2
11
  import { splitToolResultSurfaces } from './tool-result-surfaces.js';
3
12
  /**
@@ -64,6 +73,147 @@ export function resultContent({ result }) {
64
73
  return result.error ?? 'Tool execution failed.';
65
74
  }
66
75
  }
76
+ /**
77
+ * Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
78
+ * invocation's own transport signal. `dispose()` detaches both listeners once the call settles.
79
+ */
80
+ function linkCancellation({ lifecycle, runId }, { signal }) {
81
+ const controller = new AbortController();
82
+ const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => controller.abort() });
83
+ const abortFromTransport = () => controller.abort();
84
+ if (signal?.aborted)
85
+ controller.abort();
86
+ else
87
+ signal?.addEventListener('abort', abortFromTransport, { once: true });
88
+ return {
89
+ controller,
90
+ dispose: () => {
91
+ unsubscribeCancel();
92
+ signal?.removeEventListener('abort', abortFromTransport);
93
+ },
94
+ };
95
+ }
96
+ /**
97
+ * The seam a handler needs in order to show something it then WAITS on.
98
+ *
99
+ * Surfaces normally ride out in the return value and are split out below — which is useless to a
100
+ * handler that cannot return until the human has answered the very thing it wants to display.
101
+ * Emitting through here reaches the same run event stream, so a message pushed mid-call is
102
+ * indistinguishable to a renderer from one carried by the result.
103
+ *
104
+ * **Channel-neutral by construction.** The handler names its own channel, and this only injects
105
+ * correlation. That is what lets one seam drive `mcp-ui`, A2UI's multi-turn envelope
106
+ * (`createSurface` → `updateComponents` → …), the protocol's own `surface_request`/
107
+ * `surface_response` pair, or a channel invented later — none of which this file needs to know
108
+ * the shape of, matching `@jini-ai/protocol`'s own posture of typing each channel's body
109
+ * `unknown` and validating where the concrete type is known.
110
+ *
111
+ * Callable any number of times before the call settles; refused after. A handler that stashed
112
+ * this emitter could otherwise paint a surface onto a run whose `tool_result` is already on
113
+ * screen, with no call left to answer it.
114
+ */
115
+ function createSurfaceSeam({ lifecycle, runId, toolUseId }) {
116
+ let settled = false;
117
+ // A handler emits a surface mid-call because it is about to WAIT on the human (a form, a card).
118
+ // Nothing reaches the run's event stream while they answer, so the slow-run watchdog would read
119
+ // that silence as a stall and post "Still working…" over an open form — which then reappears
120
+ // once the form is answered. Suspended from the first surface until the call settles (the
121
+ // `finally` below); the watchdog re-arms then, so a genuine stall afterwards is still caught.
122
+ let slowRunSuspended = false;
123
+ const emitSurface = async (emission) => {
124
+ if (settled)
125
+ throw new Error(`emitSurface: tool call ${toolUseId} has already completed`);
126
+ if (!slowRunSuspended) {
127
+ slowRunSuspended = true;
128
+ lifecycle.suspendSlowRunNotice({ runId: runId });
129
+ }
130
+ // Cast because `RunAgentPayload` is a closed union and `channel` is deliberately an open
131
+ // string — a seam that only accepts channels this file already knows about is not a
132
+ // channel-neutral seam. This mirrors `@jini-ai/protocol`'s own posture of typing each
133
+ // channel's BODY `unknown` and validating where the concrete type is known, applied one level
134
+ // up to the channel name itself.
135
+ //
136
+ // Blast radius of a bogus channel is bounded and worth stating: this path is human-only, so
137
+ // the failure mode is an event no renderer subscribes to — nothing rendered. It cannot put
138
+ // anything into model context, which is the property that would actually matter.
139
+ //
140
+ // That bound holds only for an UNKNOWN channel, which is why the spread order below matters.
141
+ // `payload` used to be spread LAST, so a handler could set `type` to a channel this bridge
142
+ // does know — `tool_result`, `mcp-ui` — and set `toolUseId` to a DIFFERENT call's, producing
143
+ // a well-formed forged event correlated against someone else's tool call. The cast is what
144
+ // let it past the closed `RunAgentPayload` union unnoticed. `SurfaceEmission.payload` is
145
+ // documented as "the channel's own fields, merged into the emitted event BESIDE `type`" —
146
+ // beside, not over — so both reserved keys are stripped rather than merely out-ordered.
147
+ // Stripping, not reordering: `correlationFor` returns `{}` for a channel that carries no
148
+ // `toolUseId`, so spread order alone would still let a payload smuggle one onto those.
149
+ const safePayload = { ...emission.payload };
150
+ delete safePayload['type'];
151
+ delete safePayload['toolUseId'];
152
+ const data = {
153
+ ...safePayload,
154
+ type: emission.channel,
155
+ ...correlationFor(emission.channel, toolUseId),
156
+ };
157
+ await lifecycle.emit({ runId: runId, input: { event: 'agent', data } });
158
+ };
159
+ /** Refuses every later emission: the call has a result. */
160
+ const settle = () => {
161
+ settled = true;
162
+ };
163
+ /** Re-arms the slow-run notice if a surface suspended it — run last, once the call's events are out. */
164
+ const release = () => {
165
+ if (slowRunSuspended)
166
+ lifecycle.resumeSlowRunNotice({ runId: runId });
167
+ };
168
+ return { emitSurface, settle, release };
169
+ }
170
+ /** 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 });
186
+ // THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
187
+ // receives, so a UI resource left inside it is model-visible context no matter what any
188
+ // downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
189
+ // into a single text block. This is the only point holding BOTH the raw result and the
190
+ // run's event stream, which is why the split happens here rather than later.
191
+ //
192
+ // Whitelist, not blacklist: only block types known to be model-safe survive into `output`;
193
+ // everything else is withheld and emitted for the human. See `tool-result-surfaces.ts` for
194
+ // why that direction is load-bearing.
195
+ const { modelOutput, surfaces } = splitToolResultSurfaces({ output: executed.output });
196
+ const result = surfaces.length === 0 ? executed : { ...executed, output: modelOutput };
197
+ // Emitted BEFORE `tool_result` so the surface is on screen by the time the transcript shows
198
+ // the call completing — otherwise the human is asked to confirm something not yet visible.
199
+ for (const surface of surfaces) {
200
+ await lifecycle.emit({ runId: runId, input: {
201
+ event: 'agent',
202
+ data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
203
+ } });
204
+ }
205
+ await lifecycle.emit({ runId: runId, input: {
206
+ event: 'agent',
207
+ data: {
208
+ type: 'tool_result',
209
+ toolUseId,
210
+ content: resultContent({ result }),
211
+ ...(result.status === 'completed' && (batchOutcomeFor({ output: result.output }) ?? 'completed') === 'completed' ? {} : { isError: true }),
212
+ ...(media.length > 0 ? { media } : {}),
213
+ },
214
+ } });
215
+ return result;
216
+ }
67
217
  /**
68
218
  * Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
69
219
  * other host protocols. This module does not invent a server transport: a
@@ -73,135 +223,26 @@ export function resultContent({ result }) {
73
223
  export function createDelegatedToolBridge(options) {
74
224
  const { lifecycle, toolExecutor } = options;
75
225
  async function execute(requiredArgs, optionalArgs = {}) {
226
+ // Merged rather than read from `optionalArgs` alone: a caller may hand `signal` in the first
227
+ // object (the delegated-tools HTTP route does), and it must still be honoured.
76
228
  const invocation = { ...requiredArgs, ...optionalArgs };
77
229
  const { runId, toolUseId, toolId, principal, input } = invocation;
230
+ const call = { lifecycle, runId, toolUseId };
78
231
  await lifecycle.emit({ runId: runId, input: {
79
232
  event: 'agent',
80
233
  data: { type: 'tool_use', id: toolUseId, name: toolId, input },
81
234
  } });
82
- const controller = new AbortController();
83
- const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => controller.abort() });
84
- const abortFromTransport = () => controller.abort();
85
- if (invocation.signal) {
86
- if (invocation.signal.aborted)
87
- controller.abort();
88
- else
89
- invocation.signal.addEventListener('abort', abortFromTransport, { once: true });
90
- }
235
+ const cancellation = linkCancellation(call, invocation);
236
+ const seam = createSurfaceSeam(call);
91
237
  const run = { id: runId };
92
- /**
93
- * The seam a handler needs in order to show something it then WAITS on.
94
- *
95
- * Surfaces normally ride out in the return value and are split out below — which is useless to a
96
- * handler that cannot return until the human has answered the very thing it wants to display.
97
- * Emitting through here reaches the same run event stream, so a message pushed mid-call is
98
- * indistinguishable to a renderer from one carried by the result.
99
- *
100
- * **Channel-neutral by construction.** The handler names its own channel, and this only injects
101
- * correlation. That is what lets one seam drive `mcp-ui`, A2UI's multi-turn envelope
102
- * (`createSurface` → `updateComponents` → …), the protocol's own `surface_request`/
103
- * `surface_response` pair, or a channel invented later — none of which this file needs to know
104
- * the shape of, matching `@jini-ai/protocol`'s own posture of typing each channel's body
105
- * `unknown` and validating where the concrete type is known.
106
- *
107
- * Callable any number of times before the call settles; refused after. A handler that stashed
108
- * this emitter could otherwise paint a surface onto a run whose `tool_result` is already on
109
- * screen, with no call left to answer it.
110
- */
111
- let settled = false;
112
- // A handler emits a surface mid-call because it is about to WAIT on the human (a form, a card).
113
- // Nothing reaches the run's event stream while they answer, so the slow-run watchdog would read
114
- // that silence as a stall and post "Still working…" over an open form — which then reappears
115
- // once the form is answered. Suspended from the first surface until the call settles (the
116
- // `finally` below); the watchdog re-arms then, so a genuine stall afterwards is still caught.
117
- let slowRunSuspended = false;
118
- const emitSurface = async (emission) => {
119
- if (settled)
120
- throw new Error(`emitSurface: tool call ${toolUseId} has already completed`);
121
- if (!slowRunSuspended) {
122
- slowRunSuspended = true;
123
- lifecycle.suspendSlowRunNotice({ runId: runId });
124
- }
125
- // Cast because `RunAgentPayload` is a closed union and `channel` is deliberately an open
126
- // string — a seam that only accepts channels this file already knows about is not a
127
- // channel-neutral seam. This mirrors `@jini-ai/protocol`'s own posture of typing each
128
- // channel's BODY `unknown` and validating where the concrete type is known, applied one level
129
- // up to the channel name itself.
130
- //
131
- // Blast radius of a bogus channel is bounded and worth stating: this path is human-only, so
132
- // the failure mode is an event no renderer subscribes to — nothing rendered. It cannot put
133
- // anything into model context, which is the property that would actually matter.
134
- //
135
- // That bound holds only for an UNKNOWN channel, which is why the spread order below matters.
136
- // `payload` used to be spread LAST, so a handler could set `type` to a channel this bridge
137
- // does know — `tool_result`, `mcp-ui` — and set `toolUseId` to a DIFFERENT call's, producing
138
- // a well-formed forged event correlated against someone else's tool call. The cast is what
139
- // let it past the closed `RunAgentPayload` union unnoticed. `SurfaceEmission.payload` is
140
- // documented as "the channel's own fields, merged into the emitted event BESIDE `type`" —
141
- // beside, not over — so both reserved keys are stripped rather than merely out-ordered.
142
- // Stripping, not reordering: `correlationFor` returns `{}` for a channel that carries no
143
- // `toolUseId`, so spread order alone would still let a payload smuggle one onto those.
144
- const safePayload = { ...emission.payload };
145
- delete safePayload['type'];
146
- delete safePayload['toolUseId'];
147
- const data = {
148
- ...safePayload,
149
- type: emission.channel,
150
- ...correlationFor(emission.channel, toolUseId),
151
- };
152
- await lifecycle.emit({ runId: runId, input: { event: 'agent', data } });
153
- };
154
238
  try {
155
239
  try {
156
- const executed = await toolExecutor.execute({ principal: principal, run: run, toolId: toolId, input: input }, { signal: controller.signal, emitSurface: emitSurface });
157
- settled = true;
158
- // Recognized media blocks (images) are collected here purely to populate the sibling
159
- // `media` field the `tool_result` event carries below, for a UI that wants to render one
160
- // inline without re-parsing `content` (`ToolCard`'s `<img>`, per `assistant_demo_image`'s
161
- // own doc). This does NOT remove the image block from `executed.output` — `image` is one
162
- // of `tool-result-surfaces.ts`'s own `MODEL_VISIBLE_BLOCK_TYPES`, so the split below already
163
- // keeps it in `modelOutput` on its own. An image reaching the model IS the point
164
- // (`assistant_demo_image`/`admin.capture_screenshot` exist to prove exactly that round
165
- // trip) — unlike an `mcp-ui` resource, which is genuinely human-only and must never survive
166
- // into `modelOutput`. (Earlier this extraction fed its own de-imaged `remainder` into the
167
- // surfaces split below and discarded the image from `output` entirely, on the assumption
168
- // that the sibling `media` field was the only channel a caller needed — true for a UI
169
- // reading the run's event stream, false for `@jini-ai/mcp`'s `execute_delegated_tool`, which
170
- // hands the bridge's RETURN VALUE to the model with no access to that event stream at all.)
171
- const { media } = extractResultMedia({ output: executed.output });
172
- // THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
173
- // receives, so a UI resource left inside it is model-visible context no matter what any
174
- // downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
175
- // into a single text block. This is the only point holding BOTH the raw result and the
176
- // run's event stream, which is why the split happens here rather than later.
177
- //
178
- // Whitelist, not blacklist: only block types known to be model-safe survive into `output`;
179
- // everything else is withheld and emitted for the human. See `tool-result-surfaces.ts` for
180
- // why that direction is load-bearing.
181
- const { modelOutput, surfaces } = splitToolResultSurfaces({ output: executed.output });
182
- const result = surfaces.length === 0 ? executed : { ...executed, output: modelOutput };
183
- // Emitted BEFORE `tool_result` so the surface is on screen by the time the transcript shows
184
- // the call completing — otherwise the human is asked to confirm something not yet visible.
185
- for (const surface of surfaces) {
186
- await lifecycle.emit({ runId: runId, input: {
187
- event: 'agent',
188
- data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
189
- } });
190
- }
191
- await lifecycle.emit({ runId: runId, input: {
192
- event: 'agent',
193
- data: {
194
- type: 'tool_result',
195
- toolUseId,
196
- content: resultContent({ result }),
197
- ...(result.status === 'completed' ? {} : { isError: true }),
198
- ...(media.length > 0 ? { media } : {}),
199
- },
200
- } });
201
- return result;
240
+ const executed = await toolExecutor.execute({ principal: principal, run: run, toolId: toolId, input: input }, { signal: cancellation.controller.signal, emitSurface: seam.emitSurface });
241
+ seam.settle();
242
+ return await emitExecutedResult(call, { executed });
202
243
  }
203
244
  catch (error) {
204
- settled = true;
245
+ seam.settle();
205
246
  await lifecycle.emit({ runId: runId, input: {
206
247
  event: 'agent',
207
248
  data: { type: 'tool_result', toolUseId, content: errorMessage(error), isError: true },
@@ -210,10 +251,8 @@ export function createDelegatedToolBridge(options) {
210
251
  }
211
252
  }
212
253
  finally {
213
- unsubscribeCancel();
214
- invocation.signal?.removeEventListener('abort', abortFromTransport);
215
- if (slowRunSuspended)
216
- lifecycle.resumeSlowRunNotice({ runId: runId });
254
+ cancellation.dispose();
255
+ seam.release();
217
256
  }
218
257
  }
219
258
  return { execute };
@@ -19,6 +19,26 @@
19
19
  */
20
20
  export type { EventLog, EventLogAppendInput, EventLogAppendOptions, EventLogEntry, EventLogReplayResult, } from '@jini-ai/protocol';
21
21
  import type { EventLog } from '@jini-ai/protocol';
22
+ import { type SchedulerPort } from './scheduler.js';
23
+ /**
24
+ * Default time a terminal run's full payloads stay in memory after its `'end'` entry before they are
25
+ * compacted down to that `'end'` entry alone. A finished run's in-memory log only has to cover a
26
+ * client reconnecting and replaying, which takes minutes; a host that needs the full history long
27
+ * term keeps a durable copy (a product transcript, or `@jini-ai/daemon/store/event-log/sqlite`).
28
+ * Without this, every event of every run since boot stayed on the heap — measured at 118 MB of a
29
+ * 430 MB daemon heap, mostly base64 screenshot tool results (memory investigation 2026-10-21).
30
+ * Each delegated tool result is held twice while retained: the bridge's own `tool_result` and the
31
+ * CLI's MCP-wrapped `tool_result` for the same call. They are not deduplicated here because their
32
+ * ids are uncorrelated (the MCP server mints a fresh `toolUseId` per call) and their content differs
33
+ * in shape, and hosts persist transcripts from this stream; this retention window bounds both.
34
+ */
35
+ export declare const DEFAULT_TERMINAL_EVENT_RETENTION_MS: number;
36
+ /**
37
+ * Default cap on runs held at all. Matches `RunLifecycle`'s own `DEFAULT_MAX_TERMINAL_RUNS`, so a
38
+ * terminal run record the lifecycle still retains can still replay its `'end'` entry. Only ended
39
+ * runs are ever evicted to honour it — see {@link InMemoryEventLogOptions.maxRuns}.
40
+ */
41
+ export declare const DEFAULT_MAX_EVENT_LOG_RUNS = 1000;
22
42
  export interface InMemoryEventLogOptions {
23
43
  /**
24
44
  * Maximum entries retained per run before the oldest are evicted. Eviction is opt-in: if
@@ -30,6 +50,23 @@ export interface InMemoryEventLogOptions {
30
50
  readonly maxEntriesPerRun?: number;
31
51
  /** Host clock; defaults to Date.now to preserve recorded timestamps. */
32
52
  readonly now?: () => number;
53
+ /**
54
+ * How long after a run's `'end'` entry its other entries are kept before the run is compacted to
55
+ * that `'end'` entry alone (a replay from `null` then reports `truncated`; a reconnect cursor older
56
+ * than it reports `replay-gap`, both existing port outcomes). Any later append to the run (a
57
+ * `resume()`) cancels the pending compaction. `null` keeps every entry forever (the old behaviour).
58
+ * Defaults to {@link DEFAULT_TERMINAL_EVENT_RETENTION_MS}.
59
+ */
60
+ readonly terminalRetentionMs?: number | null;
61
+ /**
62
+ * Cap on runs held. Past it, the oldest *ended* runs are dropped entirely; a live run (no `'end'`
63
+ * yet, or resumed since) is never evicted to make room, so with more live runs than the cap the
64
+ * log holds every live run and no ended one. `null` disables the cap. Defaults to
65
+ * {@link DEFAULT_MAX_EVENT_LOG_RUNS}.
66
+ */
67
+ readonly maxRuns?: number | null;
68
+ /** Timer port for the compaction above; defaults to unref'd native timers, so it never keeps a process alive. */
69
+ readonly scheduler?: SchedulerPort;
33
70
  }
34
71
  /**
35
72
  * Reference `EventLog` implementation: a per-run FIFO array, optionally capped at
@@ -39,11 +76,17 @@ export interface InMemoryEventLogOptions {
39
76
  * durable copy — matching this task's scope (a real persistent adapter is `@jini-ai/daemon/store/event-log/sqlite`'s
40
77
  * job).
41
78
  *
79
+ * Memory is bounded by default: an ended run is compacted to its `'end'` entry after
80
+ * `terminalRetentionMs`, and at most `maxRuns` runs are held (ended ones evicted first).
81
+ *
42
82
  * @param options.maxEntriesPerRun - Retention cap per run, see {@link InMemoryEventLogOptions}.
83
+ * @param options.terminalRetentionMs - Post-`'end'` retention, see {@link InMemoryEventLogOptions}.
84
+ * @param options.maxRuns - Run cap, see {@link InMemoryEventLogOptions}.
43
85
  * @returns An `EventLog` port implementation.
44
86
  * @complexity `append`/`drop` are O(1) amortized (O(k) only on the eviction
45
- * splice, k = entries over cap, which is normally 1). `replay` is O(n) in
46
- * the number of retained entries for the run.
87
+ * splice, k = entries over cap, which is normally 1; the run-cap pass is O(1)
88
+ * per append once at the cap). `replay` is O(n) in the number of retained
89
+ * entries for the run.
47
90
  */
48
91
  export declare function createInMemoryEventLog(_requiredArgs: Record<string, never>, options?: InMemoryEventLogOptions): EventLog;
49
92
  //# sourceMappingURL=event-log.d.ts.map
package/dist/event-log.js CHANGED
@@ -1,3 +1,42 @@
1
+ import { createTimeoutScheduler } from './scheduler.js';
2
+ /**
3
+ * The protocol event that closes a run (`RunLifecycle.finish()` appends it). Seeing it is how this
4
+ * in-memory log knows a run's payloads have become history rather than live output.
5
+ */
6
+ const TERMINAL_EVENT = 'end';
7
+ /**
8
+ * Default time a terminal run's full payloads stay in memory after its `'end'` entry before they are
9
+ * compacted down to that `'end'` entry alone. A finished run's in-memory log only has to cover a
10
+ * client reconnecting and replaying, which takes minutes; a host that needs the full history long
11
+ * term keeps a durable copy (a product transcript, or `@jini-ai/daemon/store/event-log/sqlite`).
12
+ * Without this, every event of every run since boot stayed on the heap — measured at 118 MB of a
13
+ * 430 MB daemon heap, mostly base64 screenshot tool results (memory investigation 2026-10-21).
14
+ * Each delegated tool result is held twice while retained: the bridge's own `tool_result` and the
15
+ * CLI's MCP-wrapped `tool_result` for the same call. They are not deduplicated here because their
16
+ * ids are uncorrelated (the MCP server mints a fresh `toolUseId` per call) and their content differs
17
+ * in shape, and hosts persist transcripts from this stream; this retention window bounds both.
18
+ */
19
+ export const DEFAULT_TERMINAL_EVENT_RETENTION_MS = 15 * 60 * 1000;
20
+ /**
21
+ * Default cap on runs held at all. Matches `RunLifecycle`'s own `DEFAULT_MAX_TERMINAL_RUNS`, so a
22
+ * terminal run record the lifecycle still retains can still replay its `'end'` entry. Only ended
23
+ * runs are ever evicted to honour it — see {@link InMemoryEventLogOptions.maxRuns}.
24
+ */
25
+ export const DEFAULT_MAX_EVENT_LOG_RUNS = 1000;
26
+ /**
27
+ * Shrinks an ended run to its newest entry (its `'end'`), keeping `nextId` so cursors stay valid.
28
+ * Dedupe keys pointing at a compacted entry are forgotten with it (no production caller passes a
29
+ * `dedupeKey` today); keeping them would keep the payloads alive.
30
+ */
31
+ function compactRunLog(runLog) {
32
+ runLog.cancelCompaction = undefined;
33
+ runLog.entries.splice(0, runLog.entries.length - 1);
34
+ const retained = runLog.entries[0];
35
+ for (const [key, entry] of runLog.dedupeIndex) {
36
+ if (entry !== retained)
37
+ runLog.dedupeIndex.delete(key);
38
+ }
39
+ }
1
40
  /**
2
41
  * Reference `EventLog` implementation: a per-run FIFO array, optionally capped at
3
42
  * `maxEntriesPerRun` (opt-in — see {@link InMemoryEventLogOptions}), functionally a ring
@@ -6,24 +45,62 @@
6
45
  * durable copy — matching this task's scope (a real persistent adapter is `@jini-ai/daemon/store/event-log/sqlite`'s
7
46
  * job).
8
47
  *
48
+ * Memory is bounded by default: an ended run is compacted to its `'end'` entry after
49
+ * `terminalRetentionMs`, and at most `maxRuns` runs are held (ended ones evicted first).
50
+ *
9
51
  * @param options.maxEntriesPerRun - Retention cap per run, see {@link InMemoryEventLogOptions}.
52
+ * @param options.terminalRetentionMs - Post-`'end'` retention, see {@link InMemoryEventLogOptions}.
53
+ * @param options.maxRuns - Run cap, see {@link InMemoryEventLogOptions}.
10
54
  * @returns An `EventLog` port implementation.
11
55
  * @complexity `append`/`drop` are O(1) amortized (O(k) only on the eviction
12
- * splice, k = entries over cap, which is normally 1). `replay` is O(n) in
13
- * the number of retained entries for the run.
56
+ * splice, k = entries over cap, which is normally 1; the run-cap pass is O(1)
57
+ * per append once at the cap). `replay` is O(n) in the number of retained
58
+ * entries for the run.
14
59
  */
15
60
  export function createInMemoryEventLog(_requiredArgs, options = {}) {
16
61
  const maxEntriesPerRun = options.maxEntriesPerRun;
17
62
  const now = options.now ?? Date.now;
63
+ const terminalRetentionMs = options.terminalRetentionMs === undefined ? DEFAULT_TERMINAL_EVENT_RETENTION_MS : options.terminalRetentionMs;
64
+ const maxRuns = options.maxRuns === undefined ? DEFAULT_MAX_EVENT_LOG_RUNS : options.maxRuns;
65
+ const scheduler = options.scheduler ?? createTimeoutScheduler({});
18
66
  const runs = new Map();
67
+ // Ended runs, oldest `'end'` first (Set insertion order): the only runs `enforceRunCap` may evict.
68
+ const endedRuns = new Set();
19
69
  function getOrCreateRunLog(runId) {
20
70
  let runLog = runs.get(runId);
21
71
  if (!runLog) {
22
- runLog = { entries: [], nextId: 1, dedupeIndex: new Map() };
72
+ runLog = { entries: [], nextId: 1, dedupeIndex: new Map(), cancelCompaction: undefined };
23
73
  runs.set(runId, runLog);
24
74
  }
25
75
  return runLog;
26
76
  }
77
+ function removeRun(runId) {
78
+ runs.get(runId)?.cancelCompaction?.();
79
+ runs.delete(runId);
80
+ endedRuns.delete(runId);
81
+ }
82
+ /** Tracks whether `runId` is live or ended after appending `event`, arming the post-`'end'` compaction. */
83
+ function noteRunPhase(runId, runLog, event) {
84
+ runLog.cancelCompaction?.();
85
+ runLog.cancelCompaction = undefined;
86
+ endedRuns.delete(runId);
87
+ if (event !== TERMINAL_EVENT)
88
+ return;
89
+ endedRuns.add(runId);
90
+ if (terminalRetentionMs === null)
91
+ return;
92
+ runLog.cancelCompaction = scheduler.schedule({ delayMs: terminalRetentionMs, callback: () => compactRunLog(runLog) });
93
+ }
94
+ /** Drops the oldest ended runs until at most `maxRuns` remain, or no ended run is left to drop. */
95
+ function enforceRunCap() {
96
+ if (maxRuns === null)
97
+ return;
98
+ for (const runId of endedRuns) {
99
+ if (runs.size <= maxRuns)
100
+ return;
101
+ removeRun(runId);
102
+ }
103
+ }
27
104
  return {
28
105
  async append(input, options = {}) {
29
106
  const runLog = getOrCreateRunLog(input.runId);
@@ -47,6 +124,8 @@ export function createInMemoryEventLog(_requiredArgs, options = {}) {
47
124
  if (maxEntriesPerRun !== undefined && runLog.entries.length > maxEntriesPerRun) {
48
125
  runLog.entries.splice(0, runLog.entries.length - maxEntriesPerRun);
49
126
  }
127
+ noteRunPhase(input.runId, runLog, input.event);
128
+ enforceRunCap();
50
129
  return entry;
51
130
  },
52
131
  async replay({ runId, afterCursor }) {
@@ -85,7 +164,7 @@ export function createInMemoryEventLog(_requiredArgs, options = {}) {
85
164
  return Array.from(runs.keys()).sort();
86
165
  },
87
166
  async drop({ runId }) {
88
- runs.delete(runId);
167
+ removeRun(runId);
89
168
  },
90
169
  };
91
170
  }
@@ -236,6 +236,16 @@ export interface CreateDiskAttachmentStoreOptions {
236
236
  */
237
237
  readonly retainAcrossRestarts?: boolean;
238
238
  }
239
+ /** The `ftyp` box's declared end offset (big-endian size at bytes 0-3), clamped to the bytes
240
+ * actually present so a truncated or size-inflating file can never push the brand scan past the
241
+ * captured signature window.
242
+ *
243
+ * Exported so its short-body bounds guard can be exercised directly: {@link hasAvifSignature}, its
244
+ * only caller, already requires 8 bytes before calling it, so no call through that path can reach
245
+ * the guard — but the guard is what keeps this function safe on its own and stays. */
246
+ export declare function ftypBoxEnd({ body }: {
247
+ readonly body: Uint8Array;
248
+ }, _optional?: Record<string, never>): number;
239
249
  /**
240
250
  * `true` when `body` opens with an ISO-BMFF `ftyp` box whose major brand or compatible-brand list
241
251
  * names AVIF. This is a line-for-line mirror of the CMS package's `content-type-sniffer.ts`
@@ -263,6 +273,9 @@ export interface AttachmentRecord {
263
273
  batchDirectory: string;
264
274
  dev: number;
265
275
  ino: number;
276
+ /** Absent only for legacy sidecars; filled after their first successful integrity check. */
277
+ mtimeMs?: number;
278
+ ctimeMs?: number;
266
279
  createdAt: number;
267
280
  claimedRunId?: string;
268
281
  /**
@@ -292,6 +305,8 @@ export interface RecordedAttachmentIdentity {
292
305
  readonly dev: number;
293
306
  readonly ino: number;
294
307
  readonly size: number;
308
+ readonly mtimeMs?: number;
309
+ readonly ctimeMs?: number;
295
310
  }
296
311
  /**
297
312
  * What the filesystem reports about a file right now — the subset of `fs.Stats` (plus `realpath`)
@@ -307,6 +322,8 @@ export interface ObservedAttachmentIdentity {
307
322
  readonly dev: number;
308
323
  readonly ino: number;
309
324
  readonly size: number;
325
+ readonly mtimeMs: number;
326
+ readonly ctimeMs: number;
310
327
  /** `realpath` of the file — differs from its own path when any path component became a symlink. */
311
328
  readonly canonicalPath: string;
312
329
  }
@@ -316,7 +333,9 @@ export interface ObservedAttachmentIdentity {
316
333
  * Every condition is a distinct real attack: a regular file swapped for a symlink or directory, a
317
334
  * parent directory swapped for a symlink pointing elsewhere, the file replaced by a different file
318
335
  * at the same path (new inode), the same inode truncated or appended to (new size), or the path
319
- * now resolving onto a different device.
336
+ * now resolving onto a different device. Modification/change timestamps also detect same-size
337
+ * rewrites and replacement files whose inode was recycled. Legacy records lack timestamps and
338
+ * use their original identity checks until the first verified read establishes that baseline.
320
339
  *
321
340
  * Pure and exported on purpose. Staging a *device* change for a file that keeps its path is not
322
341
  * something a test can do on a real filesystem, and a check that can only be exercised in
@@ -357,6 +376,7 @@ export declare function reserveAttachmentRecords({ attachments, records, runId,
357
376
  * Throws without releasing `claimed`'s reservations; releasing is the caller's job (`claim`'s own
358
377
  * `catch`), so a rejected claim still leaves nothing half-claimed and the caller can retry with a
359
378
  * corrected set.
379
+ * Missing legacy timestamps are filled from the verified stat; adoption persists that baseline.
360
380
  *
361
381
  * Exported so the integrity and mixed-batch checks can be exercised directly against fabricated
362
382
  * records over real files on disk, without a full `createDiskAttachmentStore` around them.
@@ -445,12 +465,15 @@ export interface PersistedAttachmentRecord {
445
465
  readonly batchId: string;
446
466
  readonly dev: number;
447
467
  readonly ino: number;
468
+ readonly mtimeMs?: number;
469
+ readonly ctimeMs?: number;
448
470
  readonly createdAt: number;
449
471
  readonly ownerId?: string;
450
472
  }
451
473
  /**
452
474
  * Turns one sidecar's parsed JSON back into an `AttachmentRecord`, or `undefined` for anything that
453
475
  * is not a well-formed record naming a file inside its own batch directory.
476
+ * Legacy sidecars may lack timestamps; adoption verifies their recorded identity before backfill.
454
477
  *
455
478
  * `batchDirectory` is re-derived here and `filePath`'s containment is re-checked against it with
456
479
  * the same parent-equality test `register` uses — the two checks that make an adopted record no
@@ -477,6 +500,8 @@ export declare function attachmentSidecarFileName({ id }: {
477
500
  * file registration accepted. Anything failing any of those is deleted — sidecar first, then the
478
501
  * file it named, and the file ONLY when containment passed, so an unparseable sidecar can never
479
502
  * make this an arbitrary-file-delete primitive for whatever the daemon can unlink.
503
+ * Legacy records use their original identity checks before timestamps are backfilled atomically.
504
+ * A backfill write failure propagates before cleanup, preserving the attachment for a later retry.
480
505
  *
481
506
  * **The retention consequence, stated rather than assumed harmless.** Because `claimedRunId` is not
482
507
  * persisted (see {@link PersistedAttachmentRecord}), a file claimed by a run that died with the