@jini-ai/daemon 0.6.17 → 0.6.22

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 (78) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent-executor/claude/mcp-config.d.ts +17 -0
  3. package/dist/agent-executor/claude/mcp-config.js +29 -0
  4. package/dist/agent-executor/codex/home-config.d.ts +7 -6
  5. package/dist/agent-executor/codex/home-config.js +15 -3
  6. package/dist/agent-executor/codex/home-staging.d.ts +4 -2
  7. package/dist/agent-executor/codex/home-staging.js +5 -3
  8. package/dist/agent-executor/contracts.d.ts +15 -0
  9. package/dist/agent-executor/event-translation.js +3 -0
  10. package/dist/agent-executor/events.d.ts +6 -4
  11. package/dist/agent-executor/events.js +29 -7
  12. package/dist/agent-executor/gemini/tool-calls.d.ts +7 -0
  13. package/dist/agent-executor/gemini/tool-calls.js +9 -0
  14. package/dist/agent-executor/http-mcp.d.ts +23 -0
  15. package/dist/agent-executor/http-mcp.js +74 -0
  16. package/dist/agent-executor/launch.js +43 -11
  17. package/dist/agent-executor/mcp-bridge.d.ts +10 -2
  18. package/dist/agent-executor/mcp-bridge.js +2 -2
  19. package/dist/agent-executor/opencode/mcp-config.d.ts +10 -0
  20. package/dist/agent-executor/opencode/mcp-config.js +11 -0
  21. package/dist/agent-executor/opencode/tool-calls.d.ts +7 -0
  22. package/dist/agent-executor/opencode/tool-calls.js +12 -0
  23. package/dist/agent-executor/sanitized-line-stream.d.ts +24 -0
  24. package/dist/agent-executor/sanitized-line-stream.js +45 -0
  25. package/dist/agent-executor/tool-call-receipts.d.ts +7 -0
  26. package/dist/agent-executor/tool-call-receipts.js +9 -0
  27. package/dist/core/read-only-tools.d.ts +2 -0
  28. package/dist/core/read-only-tools.js +19 -3
  29. package/dist/delegated-tool-bridge.js +6 -1
  30. package/dist/http/attachments.d.ts +2 -101
  31. package/dist/http/attachments.js +13 -1
  32. package/dist/http/connectors.js +3 -1
  33. package/dist/http/db-ops.js +3 -1
  34. package/dist/http/delegated-tools.d.ts +13 -1
  35. package/dist/http/delegated-tools.js +23 -6
  36. package/dist/http/frontend-control.d.ts +0 -36
  37. package/dist/http/frontend-control.js +39 -1
  38. package/dist/http/frontend-sessions.js +4 -2
  39. package/dist/http/index.d.ts +2 -1
  40. package/dist/http/index.js +1 -0
  41. package/dist/http/media.js +3 -1
  42. package/dist/http/model-proxy.js +4 -2
  43. package/dist/http/remote-run-events.js +3 -1
  44. package/dist/http/research.js +3 -2
  45. package/dist/http/runs.js +3 -1
  46. package/dist/http/surface-restoration.d.ts +17 -0
  47. package/dist/http/surface-restoration.js +50 -0
  48. package/dist/http/terminals.js +3 -1
  49. package/dist/http/tool-catalog.d.ts +15 -1
  50. package/dist/http/tool-execution-routes.d.ts +3 -1
  51. package/dist/http/tool-execution-routes.js +4 -3
  52. package/dist/http/xai.js +3 -2
  53. package/dist/idle-monitor.d.ts +29 -0
  54. package/dist/idle-monitor.js +64 -0
  55. package/dist/image-prompt-delivery.d.ts +1 -1
  56. package/dist/image-prompt-delivery.js +4 -3
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +1 -0
  59. package/dist/legacy-data-migration.js +3 -1
  60. package/dist/remote-attachment-lookup.d.ts +3 -1
  61. package/dist/remote-attachment-lookup.js +2 -2
  62. package/dist/routines/scheduler.js +6 -4
  63. package/dist/routing-tool-executor.d.ts +6 -1
  64. package/dist/routing-tool-executor.js +15 -2
  65. package/dist/run-lifecycle.d.ts +0 -16
  66. package/dist/run-lifecycle.js +24 -6
  67. package/dist/surface-exchanges.d.ts +5 -1
  68. package/dist/surface-exchanges.js +41 -7
  69. package/dist/surface-restoration.d.ts +38 -0
  70. package/dist/surface-restoration.js +152 -0
  71. package/dist/tool-recovery/index.d.ts +1 -0
  72. package/dist/tool-recovery/index.js +1 -0
  73. package/dist/tool-recovery/redaction.d.ts +1 -0
  74. package/dist/tool-recovery/redaction.js +4 -1
  75. package/dist/tool-recovery/stack.js +2 -1
  76. package/dist/tool-recovery/surface-lifetime.d.ts +11 -0
  77. package/dist/tool-recovery/surface-lifetime.js +41 -0
  78. package/package.json +14 -14
package/dist/index.js CHANGED
@@ -79,6 +79,7 @@
79
79
  */
80
80
  export * from './event-log.js';
81
81
  export * from './close-status.js';
82
+ export * from './idle-monitor.js';
82
83
  export * from './run-lifecycle.js';
83
84
  export * from './ai-trace-lifecycle.js';
84
85
  export * from './tool-executor.js';
@@ -16,6 +16,7 @@
16
16
  * connection opens, so it can't race a concurrent open of the destination.
17
17
  */
18
18
  import * as nodeFilesystem from 'node:fs';
19
+ import { redactSecrets } from '@jini-ai/core';
19
20
  import * as path from 'node:path';
20
21
  const DEFAULT_MARKER_FILE = '.migrated-from';
21
22
  /**
@@ -254,7 +255,8 @@ function stagePromoteAndMark({ plan, markerFile, payloadEntries, writeMarker },
254
255
  export function migrateLegacyDataDirSync(requiredArgs, optionalArgs = {}) {
255
256
  const options = { ...requiredArgs, ...optionalArgs };
256
257
  const fs = options.filesystem ?? nodeFilesystem;
257
- const log = options.logger ?? { info: ({ message }) => console.log(`[jini-migrate] ${message}`) };
258
+ // Paths and payload names are host input, so the default sink redacts the complete diagnostic.
259
+ const log = options.logger ?? { info: ({ message }) => console.log(redactSecrets({ input: `[jini-migrate] ${message}` }, { redactPii: false })) };
258
260
  const markerFile = options.markerFile ?? DEFAULT_MARKER_FILE;
259
261
  const plan = planMigration({ legacyDir: options.legacyDir, dataDir: options.dataDir, markerFile }, fs);
260
262
  if ('status' in plan)
@@ -18,6 +18,8 @@ export interface RemoteAttachmentLookupRequired {
18
18
  readonly token: string;
19
19
  }
20
20
  export interface RemoteAttachmentLookupOptional {
21
+ /** Explicit authenticated mount prefix. Omitted preserves the historical origin-root route. */
22
+ readonly pathPrefix?: string;
21
23
  /** Injectable `node:http.request`. */
22
24
  readonly request?: typeof http.request;
23
25
  }
@@ -25,5 +27,5 @@ export interface RemoteAttachmentLookupOptional {
25
27
  * Creates the remote lookup.
26
28
  * @complexity One loopback round trip per call; O(response bytes).
27
29
  */
28
- export declare function createRemoteAttachmentLookup({ baseUrl, token }: RemoteAttachmentLookupRequired, { request }?: RemoteAttachmentLookupOptional): AttachmentLookup;
30
+ export declare function createRemoteAttachmentLookup({ baseUrl, token }: RemoteAttachmentLookupRequired, { request, pathPrefix }?: RemoteAttachmentLookupOptional): AttachmentLookup;
29
31
  //# sourceMappingURL=remote-attachment-lookup.d.ts.map
@@ -56,8 +56,8 @@ function unwrap({ status, body }) {
56
56
  * Creates the remote lookup.
57
57
  * @complexity One loopback round trip per call; O(response bytes).
58
58
  */
59
- export function createRemoteAttachmentLookup({ baseUrl, token }, { request = http.request } = {}) {
60
- const url = new URL(ATTACHMENT_LOOKUP_ROUTE_PATH, baseUrl);
59
+ export function createRemoteAttachmentLookup({ baseUrl, token }, { request = http.request, pathPrefix = '' } = {}) {
60
+ const url = new URL(`${pathPrefix.replace(/\/$/, '')}${ATTACHMENT_LOOKUP_ROUTE_PATH}`, baseUrl);
61
61
  const call = async (payload) => unwrap(await postJson({ url, token, payload }, { request }));
62
62
  return {
63
63
  resolveForRun: async ({ ref, runId }) => ((await call({ op: 'resolveForRun', ref, runId })) ?? undefined),
@@ -20,6 +20,8 @@
20
20
  * faithful port, not a redesign, per the proposal's own warning against "casually reinventing"
21
21
  * the race-safe scheduled-slot claim below.
22
22
  */
23
+ import { inspect } from 'node:util';
24
+ import { redactSecrets } from '@jini-ai/core';
23
25
  import { randomUUID } from 'node:crypto';
24
26
  import { nextRunAtForSchedule } from './schedule.js';
25
27
  function clearRoutinePlaceholderId(value) {
@@ -43,10 +45,10 @@ export class ScheduledRunPersistenceError extends Error {
43
45
  this.name = 'ScheduledRunPersistenceError';
44
46
  }
45
47
  }
46
- /** What a failed scheduled run logs: the underlying error's message when there is one. */
48
+ /** Preserve the underlying failure (including its code) for the redacted log diagnostic. */
47
49
  function scheduledRunFailureDetail(error) {
48
50
  const cause = error instanceof ScheduledRunPersistenceError ? error.originalError : error;
49
- return cause instanceof Error ? cause.message : cause;
51
+ return cause;
50
52
  }
51
53
  function isScheduledRunPersistenceError(error) {
52
54
  return error instanceof ScheduledRunPersistenceError;
@@ -141,7 +143,7 @@ export class RoutineService {
141
143
  this.rescheduleOne({ routineId: routine.id });
142
144
  })
143
145
  .catch((error) => {
144
- console.error(`[@jini-ai/daemon] routine ${routine.id} scheduled run failed:`, scheduledRunFailureDetail(error));
146
+ console.error(`[@jini-ai/daemon] routine ${routine.id} scheduled run failed:`, redactSecrets({ input: inspect(scheduledRunFailureDetail(error)) }, { redactPii: false }));
145
147
  if (isScheduledRunPersistenceError(error)) {
146
148
  this.retryScheduledSlot(routine.id, fireAt);
147
149
  }
@@ -249,7 +251,7 @@ export class RoutineService {
249
251
  discardError = err;
250
252
  }
251
253
  if (discardError != null) {
252
- console.error(`[@jini-ai/daemon] routine ${attempt.routine.id} prepare cleanup failed:`, errorMessage(discardError));
254
+ console.error(`[@jini-ai/daemon] routine ${attempt.routine.id} prepare cleanup failed:`, redactSecrets({ input: inspect(discardError) }, { redactPii: false }));
253
255
  }
254
256
  run.projectId = clearRoutinePlaceholderId(run.projectId);
255
257
  run.conversationId = clearRoutinePlaceholderId(run.conversationId);
@@ -23,7 +23,12 @@ export interface RoutingToolExecutorRequired {
23
23
  }
24
24
  /**
25
25
  * Creates the routing executor.
26
+ * @param required Local/remote executors and the owning registry's routing predicate.
27
+ * @param optional.acquireActivity Host busy lease, released on success or failure after real work.
28
+ * @returns One executor preserving the selected route's result/error and cancellation policy.
26
29
  * @complexity O(1) plus `isLocal`'s own cost per call.
27
30
  */
28
- export declare function createRoutingToolExecutor({ local, remote, isLocal }: RoutingToolExecutorRequired, _optional?: Record<string, never>): ToolExecutor;
31
+ export declare function createRoutingToolExecutor({ local, remote, isLocal }: RoutingToolExecutorRequired, { acquireActivity }?: {
32
+ readonly acquireActivity?: () => () => void;
33
+ }): ToolExecutor;
29
34
  //# sourceMappingURL=routing-tool-executor.d.ts.map
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * Creates the routing executor.
3
+ * @param required Local/remote executors and the owning registry's routing predicate.
4
+ * @param optional.acquireActivity Host busy lease, released on success or failure after real work.
5
+ * @returns One executor preserving the selected route's result/error and cancellation policy.
3
6
  * @complexity O(1) plus `isLocal`'s own cost per call.
4
7
  */
5
- export function createRoutingToolExecutor({ local, remote, isLocal }, _optional = {}) {
8
+ export function createRoutingToolExecutor({ local, remote, isLocal }, { acquireActivity } = {}) {
6
9
  return {
7
- execute: (args, optional) => (isLocal({ toolId: args.toolId }) ? local : remote).execute(args, optional),
10
+ async execute(args, optional) {
11
+ // HTTP disconnects/cancel requests may precede actual completion. The embedding's busy
12
+ // lease follows the real executor promise, including its approval wait and cleanup.
13
+ const release = acquireActivity?.();
14
+ try {
15
+ return await (isLocal({ toolId: args.toolId }) ? local : remote).execute(args, optional);
16
+ }
17
+ finally {
18
+ release?.();
19
+ }
20
+ },
8
21
  resumeConfirmation: (args) => local.resumeConfirmation(args),
9
22
  cancel: (args) => {
10
23
  local.cancel(args);
@@ -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 type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
18
2
  import type { RunAgentPayload, RunCancelRequest, RunChunkPayload, RunErrorPayload, RunProtocolEvent, RunStartPayload, RunStatus } from '@jini-ai/protocol';
19
3
  import type { EventLog, EventLogEntry, EventLogReplayResult } from './event-log.js';
@@ -1,3 +1,21 @@
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 { inspect } from 'node:util';
18
+ import { redactSecrets } from '@jini-ai/core';
1
19
  import { randomUUID } from 'node:crypto';
2
20
  import { RUN_PROTOCOL_VERSION, isTerminalRunState } from '@jini-ai/protocol';
3
21
  import { createInactivityWatchdog } from './close-status.js';
@@ -455,11 +473,11 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
455
473
  input.onInternalError(context);
456
474
  // eslint-disable-next-line no-console
457
475
  else
458
- console.error(`[@jini-ai/daemon] internal error (${context.source}, runId=${context.runId})`, context.error);
476
+ console.error(`[@jini-ai/daemon] internal error (${context.source}, runId=${context.runId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
459
477
  }
460
478
  catch (sinkError) {
461
479
  // eslint-disable-next-line no-console
462
- console.error(`[@jini-ai/daemon] internal error sink failed (${context.source}, runId=${context.runId})`, sinkError);
480
+ console.error(`[@jini-ai/daemon] internal error sink failed (${context.source}, runId=${context.runId})`, redactSecrets({ input: inspect(sinkError) }, { redactPii: false }));
463
481
  }
464
482
  }
465
483
  /** Trims the oldest terminal records until `terminalRunOrder` is back at or under `maxTerminalRuns`. */
@@ -531,14 +549,14 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
531
549
  }
532
550
  else {
533
551
  // eslint-disable-next-line no-console
534
- console.error(`[@jini-ai/daemon] internal error (inactivity-timeout, runId=${runId})`, error);
552
+ console.error(`[@jini-ai/daemon] internal error (inactivity-timeout, runId=${runId})`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
535
553
  }
536
554
  }
537
555
  catch (sinkError) {
538
556
  // A diagnostic sink must not turn a contained timer failure into another unhandled
539
557
  // rejection.
540
558
  // eslint-disable-next-line no-console
541
- console.error(`[@jini-ai/daemon] internal error sink failed (inactivity-timeout, runId=${runId})`, sinkError);
559
+ console.error(`[@jini-ai/daemon] internal error sink failed (inactivity-timeout, runId=${runId})`, redactSecrets({ input: inspect(sinkError) }, { redactPii: false }));
542
560
  }
543
561
  }
544
562
  }
@@ -579,13 +597,13 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
579
597
  }
580
598
  else {
581
599
  // eslint-disable-next-line no-console
582
- console.error(`[@jini-ai/daemon] internal error (slow-run-notice, runId=${runId})`, error);
600
+ console.error(`[@jini-ai/daemon] internal error (slow-run-notice, runId=${runId})`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
583
601
  }
584
602
  }
585
603
  catch (sinkError) {
586
604
  // Same containment reasoning as `handleInactivityTimeout`'s own catch below it.
587
605
  // eslint-disable-next-line no-console
588
- console.error(`[@jini-ai/daemon] internal error sink failed (slow-run-notice, runId=${runId})`, sinkError);
606
+ console.error(`[@jini-ai/daemon] internal error sink failed (slow-run-notice, runId=${runId})`, redactSecrets({ input: inspect(sinkError) }, { redactPii: false }));
589
607
  }
590
608
  }
591
609
  }
@@ -68,6 +68,8 @@ export type { SchedulerPort } from './scheduler.js';
68
68
  export declare const DEFAULT_SURFACE_IDLE_TTL_MS: number;
69
69
  /**
70
70
  * Hard ceiling on one exchange's total life, regardless of activity.
71
+ * Applies only without an executor lifetime. The historical paired-transport rationale below
72
+ * describes unleased callers; run-bound cards ignore both deadlines and end with their call.
71
73
  *
72
74
  * Exists because the idle deadline alone is unbounded in aggregate — a human answering every four
73
75
  * minutes could hold a call open indefinitely, and the call is not ours to hold: it is an HTTP
@@ -92,7 +94,9 @@ export declare const DEFAULT_SURFACE_MAX_LIFETIME_MS: number;
92
94
  * @param deps.idGenerator - Id source, injected so a test can assert a known value.
93
95
  * @param deps.clock - Wall clock for {@link SurfaceExchange.expiresAtMs}. The injected scheduler owns
94
96
  * the timers; the clock dates and checks their deadlines.
95
- * @complexity O(1) per send/receive/deliver. Each exchange owns two `unref`'d timers.
97
+ * @complexity O(1) per receive, O(n) per accepted answer in active/retained exchanges, and O(t)
98
+ * per send to t observing tabs. Closed recovery entries are capped at 128. Unleased exchanges own
99
+ * two timers; run-bound exchanges own one cancellation subscription and no deadline timers.
96
100
  */
97
101
  export declare function createSurfaceExchangeStore(deps: {
98
102
  scheduler: SchedulerPort;
@@ -1,4 +1,5 @@
1
1
  export { SURFACE_EXCHANGE_ID_PARAM, SURFACE_DISMISSED_PARAM, SURFACE_TYPED_ANSWER_PARAM, } from '@jini-ai/core';
2
+ import { createSurfaceRestoration } from './surface-restoration.js';
2
3
  /**
3
4
  * How long an exchange may sit with nothing happening before it gives up.
4
5
  *
@@ -10,6 +11,8 @@ export { SURFACE_EXCHANGE_ID_PARAM, SURFACE_DISMISSED_PARAM, SURFACE_TYPED_ANSWE
10
11
  export const DEFAULT_SURFACE_IDLE_TTL_MS = 5 * 60 * 1000;
11
12
  /**
12
13
  * Hard ceiling on one exchange's total life, regardless of activity.
14
+ * Applies only without an executor lifetime. The historical paired-transport rationale below
15
+ * describes unleased callers; run-bound cards ignore both deadlines and end with their call.
13
16
  *
14
17
  * Exists because the idle deadline alone is unbounded in aggregate — a human answering every four
15
18
  * minutes could hold a call open indefinitely, and the call is not ours to hold: it is an HTTP
@@ -29,6 +32,12 @@ class SurfaceExchangeEndedError extends Error {
29
32
  }
30
33
  terminal;
31
34
  }
35
+ /** Run-bound cards use cancellation instead of a date; other callers retain both timed budgets. */
36
+ function initialSurfaceDeadlines({ leased, nowMs, idleTtlMs, maxLifetimeMs }) {
37
+ if (leased)
38
+ return { idle: Number.POSITIVE_INFINITY, lifetime: Number.POSITIVE_INFINITY };
39
+ return { idle: nowMs + idleTtlMs, lifetime: nowMs + maxLifetimeMs };
40
+ }
32
41
  /**
33
42
  * Creates an in-process store of open surface exchanges.
34
43
  *
@@ -42,13 +51,18 @@ class SurfaceExchangeEndedError extends Error {
42
51
  * @param deps.idGenerator - Id source, injected so a test can assert a known value.
43
52
  * @param deps.clock - Wall clock for {@link SurfaceExchange.expiresAtMs}. The injected scheduler owns
44
53
  * the timers; the clock dates and checks their deadlines.
45
- * @complexity O(1) per send/receive/deliver. Each exchange owns two `unref`'d timers.
54
+ * @complexity O(1) per receive, O(n) per accepted answer in active/retained exchanges, and O(t)
55
+ * per send to t observing tabs. Closed recovery entries are capped at 128. Unleased exchanges own
56
+ * two timers; run-bound exchanges own one cancellation subscription and no deadline timers.
46
57
  */
47
58
  export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_IDLE_TTL_MS, maxLifetimeMs = DEFAULT_SURFACE_MAX_LIFETIME_MS } = {}) {
48
59
  if (!Number.isFinite(idleTtlMs) || idleTtlMs <= 0 || !Number.isFinite(maxLifetimeMs) || maxLifetimeMs <= 0)
49
60
  throw new Error('surface deadlines must be finite and positive');
50
61
  const openExchanges = new Map();
51
- return {
62
+ const restoration = createSurfaceRestoration({ clock: deps.clock, defaultChannel: deps.defaultChannel,
63
+ deliver: (required, optional) => store.deliver(required, optional) });
64
+ const store = {
65
+ restore: restoration.restore,
52
66
  open({ binding, emit }, _optional = {}) {
53
67
  // Waiting without a live emitter is a deadlock; reject before allocating any timers or ID.
54
68
  if (typeof emit !== 'function')
@@ -56,13 +70,20 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
56
70
  const id = deps.idGenerator.newId();
57
71
  if (!id || openExchanges.has(id))
58
72
  throw new Error('surface exchange ID must be unique and nonempty');
73
+ const lifetime = emit.exchangeLifetime;
74
+ // Only the executor can supply this lease. Plain emitters (including bounded visitor
75
+ // confirmations) retain their existing deadlines. No card/tool needs its own TTL override.
76
+ const leased = lifetime !== undefined && lifetime.principalId === binding.principalId;
77
+ const recovery = leased ? restoration.track({ id, binding, lifetime }) : undefined;
59
78
  const inbox = [];
60
79
  const waiters = [];
61
80
  let terminal;
62
81
  let cancelIdle;
63
82
  let cancelLifetime;
64
- const lifetimeDeadline = deps.clock.nowMs() + maxLifetimeMs;
65
- let idleDeadline = deps.clock.nowMs() + idleTtlMs;
83
+ const deadlines = initialSurfaceDeadlines({ leased, nowMs: deps.clock.nowMs(), idleTtlMs, maxLifetimeMs });
84
+ const lifetimeDeadline = deadlines.lifetime;
85
+ let idleDeadline = deadlines.idle;
86
+ const closeOnAbort = () => end({ status: 'abandoned' });
66
87
  function end(reason) {
67
88
  if (terminal)
68
89
  return;
@@ -70,6 +91,8 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
70
91
  cancelIdle?.();
71
92
  cancelLifetime?.();
72
93
  openExchanges.delete(id);
94
+ lifetime?.signal.removeEventListener('abort', closeOnAbort);
95
+ recovery?.end();
73
96
  while (waiters.length > 0)
74
97
  waiters.shift()(reason);
75
98
  }
@@ -79,7 +102,7 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
79
102
  end({ status: 'expired' });
80
103
  }
81
104
  function armIdle() {
82
- if (terminal)
105
+ if (terminal || leased)
83
106
  return;
84
107
  cancelIdle?.();
85
108
  idleDeadline = deps.clock.nowMs() + idleTtlMs;
@@ -87,7 +110,8 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
87
110
  }
88
111
  // Register only after both timers are installed, so scheduler failure leaves no discoverable exchange.
89
112
  try {
90
- cancelLifetime = deps.scheduler.schedule({ delayMs: maxLifetimeMs, callback: () => end({ status: 'expired' }) });
113
+ if (!leased)
114
+ cancelLifetime = deps.scheduler.schedule({ delayMs: maxLifetimeMs, callback: () => end({ status: 'expired' }) });
91
115
  armIdle();
92
116
  }
93
117
  catch (error) {
@@ -102,6 +126,7 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
102
126
  expireIfDue();
103
127
  if (terminal)
104
128
  return;
129
+ recovery?.answered();
105
130
  armIdle();
106
131
  const waiter = waiters.shift();
107
132
  if (waiter)
@@ -112,6 +137,11 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
112
137
  inbox.push(params);
113
138
  },
114
139
  });
140
+ if (leased) {
141
+ lifetime.signal.addEventListener('abort', closeOnAbort, { once: true });
142
+ if (lifetime.signal.aborted)
143
+ closeOnAbort();
144
+ }
115
145
  return {
116
146
  id,
117
147
  async send({ emission }, _optional = {}) {
@@ -119,7 +149,10 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
119
149
  if (terminal)
120
150
  throw new SurfaceExchangeEndedError({ id, terminal });
121
151
  armIdle();
122
- await emit(emission);
152
+ const sent = recovery === undefined ? emission : { ...emission, payload: { ...emission.payload,
153
+ surfaceExchange: { exchangeId: id, toolId: binding.toolId, runId: lifetime.runId } } };
154
+ await recovery?.send({ emission: sent });
155
+ await emit(sent);
123
156
  },
124
157
  async receive(_args, _optional = {}) {
125
158
  expireIfDue();
@@ -166,6 +199,7 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
166
199
  },
167
200
  size: () => openExchanges.size,
168
201
  };
202
+ return store;
169
203
  }
170
204
  /**
171
205
  * The one-shot case: send once, wait once, close.
@@ -0,0 +1,38 @@
1
+ import type { Clock } from '@jini-ai/core/primitives';
2
+ import type { DeliverResult, SurfaceEmitter, SurfaceEmission, SurfaceExchangeBinding, SurfaceExchangeStore } from '@jini-ai/core';
3
+ type Lifetime = NonNullable<SurfaceEmitter['exchangeLifetime']>;
4
+ type Delivery = SurfaceExchangeStore['deliver'];
5
+ /** Shared recovery owner for every exchange channel. The store supplies delivery; the executor
6
+ * supplies retry, so recovery cannot bypass authorization or invent a second handler.
7
+ * @complexity O(n) per open/restore/accepted answer in active entries plus at most 128 retained
8
+ * entries; O(t) per emission to t observing tabs.
9
+ */
10
+ export declare function createSurfaceRestoration({ clock, defaultChannel, deliver }: {
11
+ clock: Clock;
12
+ defaultChannel: string;
13
+ deliver: Delivery;
14
+ }, _options?: Record<string, never>): {
15
+ track: ({ id, binding, lifetime }: {
16
+ id: string;
17
+ binding: SurfaceExchangeBinding;
18
+ lifetime: Lifetime;
19
+ }) => {
20
+ send: ({ emission }: {
21
+ emission: SurfaceEmission;
22
+ }) => Promise<void>;
23
+ answered: () => void;
24
+ end: () => void;
25
+ };
26
+ restore: (required: {
27
+ exchangeId: string;
28
+ principalId: string;
29
+ emit: SurfaceEmitter;
30
+ }, optional?: {
31
+ toolId?: string;
32
+ channel?: import("@jini-ai/core").SurfaceAnswerChannel;
33
+ params?: Record<string, unknown>;
34
+ signal?: AbortSignal;
35
+ }) => Promise<DeliverResult>;
36
+ };
37
+ export {};
38
+ //# sourceMappingURL=surface-restoration.d.ts.map
@@ -0,0 +1,152 @@
1
+ /** Only unanswered public invocations survive a call. Bounded in count, with no timer
2
+ * keeping a process alive. Submitted secrets never enter this cache; late answers stay on the
3
+ * submitting request's stack until they can be delivered to the newly authorized call. */
4
+ const MAX_RETAINED = 128;
5
+ const unavailable = () => ({ ok: false, reason: 'unknown-or-closed' });
6
+ /** Fan out display-only updates. Losing a tab must never replace the tool's authoritative result.
7
+ * @complexity O(t) in tabs observing this card. */
8
+ async function broadcast({ entry, emission }) {
9
+ await Promise.allSettled([...entry.observers].map(observer => observer(emission)));
10
+ }
11
+ /** Strict binding survives expiry and every retry; restoration cannot weaken ordinary delivery. */
12
+ function matches({ entry, principalId, toolId, channel, defaultChannel }) {
13
+ if (entry.binding.principalId !== principalId)
14
+ return false;
15
+ if (toolId !== undefined && entry.binding.toolId !== toolId)
16
+ return false;
17
+ return (entry.binding.channel ?? defaultChannel) === (channel ?? defaultChannel);
18
+ }
19
+ /** Subscribe until this request leaves or the shared work finishes. A departing tab cancels a
20
+ * replacement only when no other tab still observes it. It never cancels the original agent call.
21
+ * @complexity O(1) subscription state per request. */
22
+ async function join({ entry, emit, work, signal }, { initial } = {}) {
23
+ let leave;
24
+ const aborted = new Promise(resolve => { leave = () => resolve(unavailable()); });
25
+ const observer = emission => emit(emission);
26
+ entry.observers.add(observer);
27
+ signal?.addEventListener('abort', leave, { once: true });
28
+ if (signal?.aborted)
29
+ leave();
30
+ try {
31
+ if (initial)
32
+ await emit(initial);
33
+ return await Promise.race([work, aborted]);
34
+ }
35
+ finally {
36
+ entry.observers.delete(observer);
37
+ signal?.removeEventListener('abort', leave);
38
+ if (entry.observers.size === 0)
39
+ entry.restartAbort?.abort();
40
+ }
41
+ }
42
+ /** Restart once, even if several tabs return together. The old scrollback handle survives an
43
+ * interrupted replacement; a received answer consumes both it and the replacement handle.
44
+ * @complexity O(t) per emitted frame, plus the original executor's cost.
45
+ */
46
+ function restart({ entry }) {
47
+ if (entry.restarting)
48
+ return entry.restarting;
49
+ const controller = new AbortController();
50
+ entry.restartAbort = controller;
51
+ let identify;
52
+ entry.replacementReady = new Promise(resolve => { identify = resolve; });
53
+ delete entry.replacementId;
54
+ entry.restarting = Promise.resolve().then(async () => {
55
+ await entry.lifetime.retry({ signal: controller.signal, emit: async (emission) => {
56
+ entry.emission = emission;
57
+ const metadata = emission.payload['surfaceExchange'];
58
+ if (metadata?.exchangeId) {
59
+ entry.replacementId = metadata.exchangeId;
60
+ identify(metadata.exchangeId);
61
+ }
62
+ await broadcast({ entry, emission });
63
+ } });
64
+ return entry.replacementId === undefined ? unavailable() : { ok: true };
65
+ }).finally(() => {
66
+ identify(undefined); // Authorization/preparation can refuse before opening any replacement.
67
+ delete entry.restarting;
68
+ delete entry.restartAbort;
69
+ });
70
+ return entry.restarting;
71
+ }
72
+ /** Deliver a late answer only after a new, authorized exchange exists. The answer is not cached,
73
+ * reused as tool input or broadcast. A second tab cannot repeat an already consumed decision.
74
+ */
75
+ async function deliverLate({ entry, principalId, params, deliver }, options) {
76
+ const exchangeId = await entry.replacementReady;
77
+ if (exchangeId === undefined || entry.answered || options?.signal?.aborted)
78
+ return;
79
+ deliver({ exchangeId, principalId, params }, options);
80
+ }
81
+ /** Shared recovery owner for every exchange channel. The store supplies delivery; the executor
82
+ * supplies retry, so recovery cannot bypass authorization or invent a second handler.
83
+ * @complexity O(n) per open/restore/accepted answer in active entries plus at most 128 retained
84
+ * entries; O(t) per emission to t observing tabs.
85
+ */
86
+ export function createSurfaceRestoration({ clock, defaultChannel, deliver }, _options = {}) {
87
+ const entries = new Map();
88
+ function prune() {
89
+ const retained = [];
90
+ for (const [id, entry] of entries) {
91
+ if (entry.endedAt === undefined || entry.restarting)
92
+ continue;
93
+ retained.push(id);
94
+ }
95
+ for (const id of retained.slice(0, Math.max(0, retained.length - MAX_RETAINED)))
96
+ entries.delete(id);
97
+ }
98
+ function forgetAnswered({ id }) {
99
+ for (const [key, entry] of entries) {
100
+ if (key === id || entry.replacementId === id) {
101
+ entry.answered = true;
102
+ entries.delete(key);
103
+ }
104
+ }
105
+ }
106
+ function track({ id, binding, lifetime }) {
107
+ prune();
108
+ let finish;
109
+ const ended = new Promise(resolve => { finish = resolve; });
110
+ const entry = { binding: { ...binding }, lifetime, answered: false, observers: new Set(), finish, ended };
111
+ entries.set(id, entry);
112
+ return {
113
+ send: async ({ emission }) => {
114
+ entry.emission = emission;
115
+ await broadcast({ entry, emission });
116
+ },
117
+ answered: () => forgetAnswered({ id }),
118
+ end: () => {
119
+ entry.endedAt = clock.nowMs();
120
+ entry.finish();
121
+ // A synthetic/nested card cannot safely replay its outer tool. No emitted surface means
122
+ // nobody saw a card to return to; retaining that call would be speculative work.
123
+ if (!entry.emission || entry.answered || binding.toolId !== lifetime.toolId)
124
+ entries.delete(id);
125
+ prune();
126
+ },
127
+ };
128
+ }
129
+ const restore = async ({ exchangeId, principalId, emit }, options = {}) => {
130
+ prune();
131
+ const entry = entries.get(exchangeId);
132
+ if (!entry)
133
+ return unavailable();
134
+ if (!matches({ entry, principalId, toolId: options.toolId, channel: options.channel, defaultChannel }))
135
+ return { ok: false, reason: 'binding-mismatch' };
136
+ if (options.signal?.aborted)
137
+ return unavailable();
138
+ if (entry.endedAt === undefined) {
139
+ if (options.params)
140
+ return deliver({ exchangeId, principalId, params: options.params }, options);
141
+ return join({ entry, emit, work: entry.ended.then(() => ({ ok: true })), signal: options.signal }, entry.emission ? { initial: entry.emission } : {});
142
+ }
143
+ const work = restart({ entry });
144
+ // Subscribe synchronously before the retry's microtask can emit the first card.
145
+ const observed = join({ entry, emit, work, signal: options.signal }, entry.replacementId && entry.emission ? { initial: entry.emission } : {});
146
+ if (options.params)
147
+ await deliverLate({ entry, principalId, params: options.params, deliver }, options);
148
+ return observed;
149
+ };
150
+ return { track, restore };
151
+ }
152
+ //# sourceMappingURL=surface-restoration.js.map
@@ -5,4 +5,5 @@ export * from "./stack.js";
5
5
  export * from "./federated-refusal.js";
6
6
  export * from "./observability.js";
7
7
  export * from "./analytics.js";
8
+ export * from './surface-lifetime.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,5 @@ export * from "./stack.js";
5
5
  export * from "./federated-refusal.js";
6
6
  export * from "./observability.js";
7
7
  export * from "./analytics.js";
8
+ export * from './surface-lifetime.js';
8
9
  //# sourceMappingURL=index.js.map
@@ -77,6 +77,7 @@ export declare function isRedactedToolFailure({ result }: {
77
77
  * compiles against a published http-kit that predates `status`.
78
78
  */
79
79
  export interface DelegatedInternalErrorContext {
80
+ readonly recoveryMessage?: string;
80
81
  readonly source: string;
81
82
  readonly runId: string;
82
83
  readonly toolId: string;
@@ -97,7 +97,10 @@ function delegatedInternalErrorReason(context) {
97
97
  * @complexity O(reason length) for the redaction pass.
98
98
  */
99
99
  export function describeDelegatedInternalError({ context, ...deps }, _optional = {}) {
100
- const { text, redactions } = deps.redactSecretShapes({ text: delegatedInternalErrorReason(context) });
100
+ let reason = delegatedInternalErrorReason(context);
101
+ if ((context.status === "timed-out" || context.status === "cancelled") && context.recoveryMessage)
102
+ reason = context.recoveryMessage;
103
+ const { text, redactions } = deps.redactSecretShapes({ text: reason });
101
104
  const errorId = deps.mintErrorId();
102
105
  safeOnFailure(deps.onFailure, {
103
106
  errorId,
@@ -46,6 +46,7 @@ import { withReadOnlyToolConstraint } from "../core/read-only-tools.js";
46
46
  import { withToolAttemptAudit } from "../core/tool-audit.js";
47
47
  import { withToolFailureRecovery } from "./recovery.js";
48
48
  import { withRedactedToolFailures, readToolErrorId } from "./redaction.js";
49
+ import { withSurfaceExchangeLifetime } from './surface-lifetime.js';
49
50
  /** The audit wrapper's options: only an error sink the caller actually supplied. */
50
51
  function auditOptions(audit) {
51
52
  return audit.onSinkError ? { onSinkError: audit.onSinkError } : {};
@@ -61,6 +62,6 @@ export function createAssistantToolExecutor(deps, _optional = {}) {
61
62
  const audited = deps.toolAttemptAudit
62
63
  ? withToolAttemptAudit({ inner: redacted, ...deps.toolAttemptAudit, readErrorId: readToolErrorId }, auditOptions(deps.toolAttemptAudit))
63
64
  : redacted;
64
- return withToolFailureRecovery({ ...deps, inner: audited }, {});
65
+ return withSurfaceExchangeLifetime({ inner: withToolFailureRecovery({ ...deps, inner: audited }, {}) });
65
66
  }
66
67
  //# sourceMappingURL=stack.js.map
@@ -0,0 +1,11 @@
1
+ import type { ToolExecutor } from '../tool-executor.js';
2
+ /** Bind every card type to the executing call, and make restoration re-enter this same stack.
3
+ * No submitted answer is captured here: input is the original model-supplied invocation only.
4
+ * @param required.inner The complete authorization, recovery and audit stack.
5
+ * @returns An executor with the same cancellation and audit ports.
6
+ * @complexity O(input size) to snapshot public arguments; O(1) lifecycle state per call.
7
+ */
8
+ export declare function withSurfaceExchangeLifetime({ inner }: {
9
+ inner: ToolExecutor;
10
+ }, _options?: Record<string, never>): ToolExecutor;
11
+ //# sourceMappingURL=surface-lifetime.d.ts.map