@jini-ai/daemon 0.6.8 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +148 -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 +37 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +3 -1
  7. package/dist/agent-executor/events.d.ts +5 -0
  8. package/dist/agent-executor/events.js +143 -87
  9. package/dist/agent-executor/launch.d.ts +5 -3
  10. package/dist/agent-executor/launch.js +315 -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-result-correlation.d.ts +26 -0
  24. package/dist/delegated-result-correlation.js +68 -0
  25. package/dist/delegated-tool-bridge.d.ts +6 -14
  26. package/dist/delegated-tool-bridge.js +155 -155
  27. package/dist/event-log.d.ts +45 -2
  28. package/dist/event-log.js +83 -4
  29. package/dist/http/attachments.d.ts +26 -1
  30. package/dist/http/attachments.js +129 -65
  31. package/dist/http/connectors.d.ts +1 -32
  32. package/dist/http/connectors.js +13 -68
  33. package/dist/http/delegated-tools.d.ts +8 -1
  34. package/dist/http/delegated-tools.js +30 -30
  35. package/dist/http/host-tools.d.ts +1 -0
  36. package/dist/http/host-tools.js +64 -59
  37. package/dist/http/index.d.ts +2 -2
  38. package/dist/http/index.js +1 -1
  39. package/dist/http/media.js +48 -69
  40. package/dist/http/memory.js +45 -36
  41. package/dist/http/model-proxy-fields.d.ts +23 -0
  42. package/dist/http/model-proxy-fields.js +29 -0
  43. package/dist/http/model-proxy.js +193 -395
  44. package/dist/http/research-tavily.adapter.js +72 -49
  45. package/dist/http/research.js +33 -23
  46. package/dist/http/resource-request.d.ts +37 -0
  47. package/dist/http/resource-request.js +59 -0
  48. package/dist/http/routines.js +49 -90
  49. package/dist/http/runs.d.ts +7 -5
  50. package/dist/http/runs.js +21 -7
  51. package/dist/http/terminals.js +23 -48
  52. package/dist/http/tool-catalog.d.ts +5 -0
  53. package/dist/http/xai.d.ts +11 -1
  54. package/dist/http/xai.js +125 -124
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +1 -0
  57. package/dist/legacy-data-migration.js +41 -24
  58. package/dist/run/core/retry.js +51 -35
  59. package/dist/run/diagnostics/diagnostics.js +5 -17
  60. package/dist/run-credentials.js +49 -27
  61. package/dist/run-lifecycle.d.ts +31 -2
  62. package/dist/run-lifecycle.js +49 -42
  63. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  64. package/dist/store/agent-sessions/sqlite.js +6 -4
  65. package/dist/surface-exchanges.js +2 -1
  66. package/dist/terminal-session.d.ts +1 -1
  67. package/dist/terminal-session.js +26 -33
  68. package/dist/tool-audit.d.ts +1 -0
  69. package/dist/tool-audit.js +1 -0
  70. package/dist/tool-executor.d.ts +24 -3
  71. package/dist/tool-executor.js +276 -139
  72. package/dist/tool-recovery/recovery.js +64 -35
  73. package/dist/tool-recovery/stack.d.ts +2 -1
  74. package/dist/tool-recovery/stack.js +1 -1
  75. package/dist/tool-result-diagnostics.d.ts +27 -0
  76. package/dist/tool-result-diagnostics.js +77 -0
  77. package/dist/tool-result-media.d.ts +27 -14
  78. package/dist/tool-result-media.js +51 -38
  79. package/package.json +14 -14
@@ -1,4 +1,13 @@
1
- import { extractResultMedia } from './tool-result-media.js';
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 {} from '@jini-ai/core';
10
+ import { projectToolResultEvent } from './tool-result-media.js';
2
11
  import { splitToolResultSurfaces } from './tool-result-surfaces.js';
3
12
  /**
4
13
  * Run-protocol `type` for a human-only surface withheld from a tool result.
@@ -29,40 +38,140 @@ function correlationFor(channel, toolUseId) {
29
38
  function errorMessage(error) {
30
39
  return error instanceof Error ? error.message : String(error);
31
40
  }
32
- /** Converts arbitrary registered-tool output into the string-bearing run protocol. */
33
- export function serializeDelegatedToolOutput({ output }) {
34
- if (typeof output === 'string')
35
- return output;
36
- if (output === undefined)
37
- return '';
38
- try {
39
- const serialized = JSON.stringify(output);
40
- return serialized === undefined ? String(output) : serialized;
41
- }
42
- catch {
43
- return String(output);
44
- }
41
+ export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
42
+ /**
43
+ * Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
44
+ * invocation's own transport signal. `dispose()` detaches both listeners once the call settles.
45
+ */
46
+ function linkCancellation({ lifecycle, runId }, { signal }) {
47
+ const controller = new AbortController();
48
+ const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => controller.abort() });
49
+ const abortFromTransport = () => controller.abort();
50
+ if (signal?.aborted)
51
+ controller.abort();
52
+ else
53
+ signal?.addEventListener('abort', abortFromTransport, { once: true });
54
+ return {
55
+ controller,
56
+ dispose: () => {
57
+ unsubscribeCancel();
58
+ signal?.removeEventListener('abort', abortFromTransport);
59
+ },
60
+ };
45
61
  }
46
62
  /**
47
- * Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
48
- * gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
49
- * output/failure reason. Exported so both real callers share one mapping rather than drifting.
63
+ * The seam a handler needs in order to show something it then WAITS on.
64
+ *
65
+ * Surfaces normally ride out in the return value and are split out below — which is useless to a
66
+ * handler that cannot return until the human has answered the very thing it wants to display.
67
+ * Emitting through here reaches the same run event stream, so a message pushed mid-call is
68
+ * indistinguishable to a renderer from one carried by the result.
69
+ *
70
+ * **Channel-neutral by construction.** The handler names its own channel, and this only injects
71
+ * correlation. That is what lets one seam drive `mcp-ui`, A2UI's multi-turn envelope
72
+ * (`createSurface` → `updateComponents` → …), the protocol's own `surface_request`/
73
+ * `surface_response` pair, or a channel invented later — none of which this file needs to know
74
+ * the shape of, matching `@jini-ai/protocol`'s own posture of typing each channel's body
75
+ * `unknown` and validating where the concrete type is known.
76
+ *
77
+ * Callable any number of times before the call settles; refused after. A handler that stashed
78
+ * this emitter could otherwise paint a surface onto a run whose `tool_result` is already on
79
+ * screen, with no call left to answer it.
50
80
  */
51
- export function resultContent({ result }) {
52
- switch (result.status) {
53
- case 'completed':
54
- return serializeDelegatedToolOutput({ output: result.output });
55
- case 'denied':
56
- return 'Tool execution denied by policy.';
57
- case 'confirmation-denied':
58
- return 'Tool execution denied during confirmation.';
59
- case 'timed-out':
60
- return 'Tool execution timed out.';
61
- case 'cancelled':
62
- return 'Tool execution cancelled.';
63
- case 'failed':
64
- return result.error ?? 'Tool execution failed.';
81
+ function createSurfaceSeam({ lifecycle, runId, toolUseId }) {
82
+ let settled = false;
83
+ // A handler emits a surface mid-call because it is about to WAIT on the human (a form, a card).
84
+ // Nothing reaches the run's event stream while they answer, so the slow-run watchdog would read
85
+ // that silence as a stall and post "Still working…" over an open form — which then reappears
86
+ // once the form is answered. Suspended from the first surface until the call settles (the
87
+ // `finally` below); the watchdog re-arms then, so a genuine stall afterwards is still caught.
88
+ let slowRunSuspended = false;
89
+ const emitSurface = async (emission) => {
90
+ if (settled)
91
+ throw new Error(`emitSurface: tool call ${toolUseId} has already completed`);
92
+ if (!slowRunSuspended) {
93
+ slowRunSuspended = true;
94
+ lifecycle.suspendSlowRunNotice({ runId: runId });
95
+ }
96
+ // Cast because `RunAgentPayload` is a closed union and `channel` is deliberately an open
97
+ // string — a seam that only accepts channels this file already knows about is not a
98
+ // channel-neutral seam. This mirrors `@jini-ai/protocol`'s own posture of typing each
99
+ // channel's BODY `unknown` and validating where the concrete type is known, applied one level
100
+ // up to the channel name itself.
101
+ //
102
+ // Blast radius of a bogus channel is bounded and worth stating: this path is human-only, so
103
+ // the failure mode is an event no renderer subscribes to — nothing rendered. It cannot put
104
+ // anything into model context, which is the property that would actually matter.
105
+ //
106
+ // That bound holds only for an UNKNOWN channel, which is why the spread order below matters.
107
+ // `payload` used to be spread LAST, so a handler could set `type` to a channel this bridge
108
+ // does know — `tool_result`, `mcp-ui` — and set `toolUseId` to a DIFFERENT call's, producing
109
+ // a well-formed forged event correlated against someone else's tool call. The cast is what
110
+ // let it past the closed `RunAgentPayload` union unnoticed. `SurfaceEmission.payload` is
111
+ // documented as "the channel's own fields, merged into the emitted event BESIDE `type`" —
112
+ // beside, not over — so both reserved keys are stripped rather than merely out-ordered.
113
+ // Stripping, not reordering: `correlationFor` returns `{}` for a channel that carries no
114
+ // `toolUseId`, so spread order alone would still let a payload smuggle one onto those.
115
+ const safePayload = { ...emission.payload };
116
+ delete safePayload['type'];
117
+ delete safePayload['toolUseId'];
118
+ const data = {
119
+ ...safePayload,
120
+ type: emission.channel,
121
+ ...correlationFor(emission.channel, toolUseId),
122
+ };
123
+ await lifecycle.emit({ runId: runId, input: { event: 'agent', data } });
124
+ };
125
+ /** Refuses every later emission: the call has a result. */
126
+ const settle = () => {
127
+ settled = true;
128
+ };
129
+ /** Re-arms the slow-run notice if a surface suspended it — run last, once the call's events are out. */
130
+ const release = () => {
131
+ if (slowRunSuspended)
132
+ lifecycle.resumeSlowRunNotice({ runId: runId });
133
+ };
134
+ return { emitSurface, settle, release };
135
+ }
136
+ /** Splits human-only surfaces out of an executed result, emits them, then the matching `tool_result`. */
137
+ async function emitExecutedResult({ lifecycle, runId, toolUseId, delegatedResultCorrelation }, { executed }) {
138
+ // THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
139
+ // receives, so a UI resource left inside it is model-visible context no matter what any
140
+ // downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
141
+ // into a single text block. This is the only point holding BOTH the raw result and the
142
+ // run's event stream, which is why the split happens here rather than later.
143
+ //
144
+ // Whitelist, not blacklist: only block types known to be model-safe survive into `output`;
145
+ // everything else is withheld and emitted for the human. See `tool-result-surfaces.ts` for
146
+ // why that direction is load-bearing.
147
+ const { modelOutput, surfaces } = splitToolResultSurfaces({ output: executed.output });
148
+ const result = surfaces.length === 0 ? executed : { ...executed, output: modelOutput };
149
+ // Emitted BEFORE `tool_result` so the surface is on screen by the time the transcript shows
150
+ // the call completing — otherwise the human is asked to confirm something not yet visible.
151
+ for (const surface of surfaces) {
152
+ await lifecycle.emit({ runId: runId, input: {
153
+ event: 'agent',
154
+ data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
155
+ } });
65
156
  }
157
+ // The return keeps model-visible images; only the stored event removes their duplicate.
158
+ const projection = projectToolResultEvent({ result });
159
+ await lifecycle.emit({ runId: runId, input: {
160
+ event: 'agent',
161
+ data: {
162
+ type: 'tool_result',
163
+ toolUseId,
164
+ ...projection,
165
+ },
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
+ }
171
+ return result;
172
+ }
173
+ function isContentEnvelope(output) {
174
+ return typeof output === 'object' && output !== null && 'content' in output && Array.isArray(output.content);
66
175
  }
67
176
  /**
68
177
  * Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
@@ -70,138 +179,31 @@ export function resultContent({ result }) {
70
179
  * concrete protocol calls this bridge after it has decoded and authenticated a
71
180
  * delegated tool request.
72
181
  */
73
- export function createDelegatedToolBridge(options) {
74
- 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;
75
186
  async function execute(requiredArgs, optionalArgs = {}) {
187
+ // Merged rather than read from `optionalArgs` alone: a caller may hand `signal` in the first
188
+ // object (the delegated-tools HTTP route does), and it must still be honoured.
76
189
  const invocation = { ...requiredArgs, ...optionalArgs };
77
190
  const { runId, toolUseId, toolId, principal, input } = invocation;
191
+ const call = { lifecycle, runId, toolUseId, ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}) };
78
192
  await lifecycle.emit({ runId: runId, input: {
79
193
  event: 'agent',
80
194
  data: { type: 'tool_use', id: toolUseId, name: toolId, input },
81
195
  } });
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
- }
196
+ const cancellation = linkCancellation(call, invocation);
197
+ const seam = createSurfaceSeam(call);
91
198
  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
199
  try {
155
200
  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;
201
+ const executed = await toolExecutor.execute({ principal: principal, run: run, toolId: toolId, input: input }, { signal: cancellation.controller.signal, emitSurface: seam.emitSurface });
202
+ seam.settle();
203
+ return await emitExecutedResult(call, { executed });
202
204
  }
203
205
  catch (error) {
204
- settled = true;
206
+ seam.settle();
205
207
  await lifecycle.emit({ runId: runId, input: {
206
208
  event: 'agent',
207
209
  data: { type: 'tool_result', toolUseId, content: errorMessage(error), isError: true },
@@ -210,10 +212,8 @@ export function createDelegatedToolBridge(options) {
210
212
  }
211
213
  }
212
214
  finally {
213
- unsubscribeCancel();
214
- invocation.signal?.removeEventListener('abort', abortFromTransport);
215
- if (slowRunSuspended)
216
- lifecycle.resumeSlowRunNotice({ runId: runId });
215
+ cancellation.dispose();
216
+ seam.release();
217
217
  }
218
218
  }
219
219
  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