@jini-ai/daemon 0.5.3 → 0.6.0

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 (77) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-executor/contracts.d.ts +45 -14
  3. package/dist/agent-executor/contracts.js +0 -9
  4. package/dist/agent-executor/event-translation.d.ts +2 -2
  5. package/dist/agent-executor/event-translation.js +0 -2
  6. package/dist/agent-executor/events.d.ts +26 -6
  7. package/dist/agent-executor/events.js +198 -11
  8. package/dist/agent-executor/handoff.d.ts +1 -1
  9. package/dist/agent-executor/handoff.js +0 -1
  10. package/dist/agent-executor/index.d.ts +1 -1
  11. package/dist/agent-executor/launch.d.ts +1 -1
  12. package/dist/agent-executor/launch.js +47 -3
  13. package/dist/agent-executor/mcp-bridge.d.ts +2 -2
  14. package/dist/agent-executor/mcp-bridge.js +19 -8
  15. package/dist/agent-executor/model-receipts.d.ts +6 -0
  16. package/dist/agent-executor/model-receipts.js +26 -0
  17. package/dist/agent-executor.d.ts +7 -1665
  18. package/dist/agent-executor.js +7 -3007
  19. package/dist/core/read-only-tools.d.ts +130 -0
  20. package/dist/core/read-only-tools.js +115 -0
  21. package/dist/core/tool-audit.d.ts +131 -0
  22. package/dist/core/tool-audit.js +113 -0
  23. package/dist/http/active-context.d.ts +2 -2
  24. package/dist/http/agents.d.ts +8 -2
  25. package/dist/http/agents.js +6 -2
  26. package/dist/http/component-catalog.d.ts +2 -2
  27. package/dist/http/connectors.d.ts +17 -17
  28. package/dist/http/daemon-status.d.ts +2 -2
  29. package/dist/http/db-ops.d.ts +3 -3
  30. package/dist/http/delegated-tools.d.ts +1 -1
  31. package/dist/http/delegated-tools.js +1 -1
  32. package/dist/http/frontend-sessions.d.ts +1 -1
  33. package/dist/http/health.d.ts +6 -6
  34. package/dist/http/host-tools.d.ts +2 -2
  35. package/dist/http/index.d.ts +2 -2
  36. package/dist/http/index.js +1 -1
  37. package/dist/http/media.d.ts +4 -4
  38. package/dist/http/memory.d.ts +15 -15
  39. package/dist/http/remote-run-events.d.ts +2 -2
  40. package/dist/http/research.d.ts +1 -1
  41. package/dist/http/route-manifest.js +2 -2
  42. package/dist/http/routines.d.ts +7 -7
  43. package/dist/http/run-credentials.d.ts +2 -2
  44. package/dist/http/run-credentials.js +2 -2
  45. package/dist/http/runs.d.ts +31 -6
  46. package/dist/http/runs.js +36 -1
  47. package/dist/http/terminals.d.ts +6 -6
  48. package/dist/http/tool-catalog.d.ts +2 -2
  49. package/dist/http/xai.d.ts +6 -6
  50. package/dist/index.d.ts +75 -571
  51. package/dist/index.js +75 -571
  52. package/dist/read-only-tools.d.ts +1 -129
  53. package/dist/read-only-tools.js +2 -114
  54. package/dist/run-credentials.d.ts +47 -23
  55. package/dist/run-credentials.js +24 -8
  56. package/dist/scheduler.d.ts +5 -0
  57. package/dist/scheduler.js +13 -1
  58. package/dist/session-coordination.d.ts +58 -24
  59. package/dist/session-coordination.js +40 -12
  60. package/dist/store/event-log/sqlite.js +3 -3
  61. package/dist/surface-exchanges.d.ts +207 -61
  62. package/dist/surface-exchanges.js +167 -20
  63. package/dist/tool-audit.d.ts +1 -113
  64. package/dist/tool-audit.js +2 -94
  65. package/dist/tool-recovery/diagnostics.d.ts +132 -0
  66. package/dist/tool-recovery/diagnostics.js +110 -0
  67. package/dist/tool-recovery/federated-refusal.d.ts +92 -0
  68. package/dist/tool-recovery/federated-refusal.js +152 -0
  69. package/dist/tool-recovery/index.d.ts +6 -0
  70. package/dist/tool-recovery/index.js +6 -0
  71. package/dist/tool-recovery/recovery.d.ts +49 -0
  72. package/dist/tool-recovery/recovery.js +419 -0
  73. package/dist/tool-recovery/redaction.d.ts +166 -0
  74. package/dist/tool-recovery/redaction.js +192 -0
  75. package/dist/tool-recovery/stack.d.ts +69 -0
  76. package/dist/tool-recovery/stack.js +62 -0
  77. package/package.json +35 -12
@@ -4,36 +4,51 @@ export interface ConversationRun {
4
4
  readonly conversationId: string;
5
5
  readonly runId: string;
6
6
  }
7
+ /** In-process occupancy only: durable answer ownership belongs to the unfinished message.
8
+ * An empty tracker after restart does not prove a CLI child died; recovery must verify its saved
9
+ * identity before native resume, otherwise reconstruct under a fresh session. */
7
10
  export interface LiveRunTracker {
8
- register(args: ConversationRun): void;
9
- unregister(args: ConversationRun): void;
10
- hasConcurrentLiveRun(args: ConversationRun): boolean;
11
- concurrentLiveRunIds(args: ConversationRun): string[];
11
+ /** Register synchronously before awaiting, so another start observes this run as live. */
12
+ register(args: ConversationRun, optional?: Record<string, never>): void;
13
+ /** Idempotent removal; safe for a run that was never registered. */
14
+ unregister(args: ConversationRun, optional?: Record<string, never>): void;
15
+ /** Excludes runId itself; concurrent processes must not resume the same CLI transcript. */
16
+ hasConcurrentLiveRun(args: ConversationRun, optional?: Record<string, never>): boolean;
17
+ concurrentLiveRunIds(args: ConversationRun, optional?: Record<string, never>): string[];
18
+ /** Undefined after unregister; tool calls use this to resolve their conversation. */
12
19
  conversationIdForRun(args: {
13
20
  runId: string;
14
- }): string | undefined;
21
+ }, optional?: Record<string, never>): string | undefined;
15
22
  }
16
- export declare function createLiveRunTracker(_args: Record<string, never>): LiveRunTracker;
23
+ /** Fresh private maps keep daemon instances independent. */
24
+ export declare function createLiveRunTracker(_args: Record<string, never>, _optional?: Record<string, never>): LiveRunTracker;
17
25
  export interface ConversationStartLock {
26
+ /** Serialize each conversation separately; undefined runs immediately. Rejection reaches only
27
+ * its caller and never poisons queued sections. Entries disappear once their queues drain. */
18
28
  run<T>(args: {
19
29
  conversationId: string | undefined;
20
30
  critical: () => Promise<T>;
21
- }): Promise<T>;
22
- trackedConversationCount(): number;
31
+ }, optional?: Record<string, never>): Promise<T>;
32
+ trackedConversationCount(required: Record<string, never>, optional?: Record<string, never>): number;
23
33
  }
24
- /** Serialize initialization only. Run execution belongs outside the critical section. */
25
- export declare function createConversationStartLock(_args: Record<string, never>): ConversationStartLock;
34
+ /** Serialize the session-binding read/decide/write so simultaneous first turns cannot mint competing
35
+ * sessions and orphan a binding. Lock only initialization: holding it across execution would queue
36
+ * turns behind minutes of work instead of allowing an immediate concurrency refusal. In-process only. */
37
+ export declare function createConversationStartLock(_args: Record<string, never>, _optional?: Record<string, never>): ConversationStartLock;
26
38
  /** Existing lifecycle port; no persistence is imported or implemented here. */
27
39
  export interface StoppingRunLifecycle {
28
40
  onCancelRequested(args: {
29
41
  runId: string;
30
42
  listener: () => void;
31
- }): () => void;
43
+ }, optional?: Record<string, never>): () => void;
32
44
  waitForTerminal(args: {
33
45
  runId: string;
34
- }): Promise<unknown>;
46
+ }, optional?: Record<string, never>): Promise<unknown>;
35
47
  }
36
48
  export declare const STOPPING_RUN_WAIT_MS = 20000;
49
+ /** Wait only for cancelled peers: cancellation records intent before the CLI exits. Active peers
50
+ * can run for minutes and stay subject to the concurrency rule. Remove ended peers here because
51
+ * ordering against their own terminal cleanup is not guaranteed. The wait is bounded by timeoutMs. */
37
52
  export declare function waitForStoppingRuns({ tracker, lifecycle, scheduler, conversationId, runId }: {
38
53
  tracker: LiveRunTracker;
39
54
  lifecycle: StoppingRunLifecycle;
@@ -52,20 +67,22 @@ export interface FailingRunLifecycle {
52
67
  message: string;
53
68
  };
54
69
  };
55
- }): Promise<unknown>;
70
+ }, optional?: Record<string, never>): Promise<unknown>;
56
71
  finish(input: {
57
72
  runId: string;
58
73
  status: 'failed';
59
74
  code: null;
60
75
  signal: null;
61
76
  resumable: false;
62
- }): Promise<unknown>;
77
+ }, optional?: Record<string, never>): Promise<unknown>;
63
78
  }
79
+ /** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
80
+ * is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
64
81
  export declare function failRunBeforeStart({ lifecycle, runId, message }: {
65
82
  lifecycle: FailingRunLifecycle;
66
83
  runId: string;
67
84
  message: string;
68
- }): Promise<void>;
85
+ }, _optional?: Record<string, never>): Promise<void>;
69
86
  export interface AgentCapabilities {
70
87
  readonly resumesSessionViaCli?: boolean;
71
88
  readonly resumesSessionViaAcpLoad?: boolean;
@@ -74,42 +91,59 @@ export interface AgentCapabilities {
74
91
  export interface AgentCapabilityResolver {
75
92
  lookup(args: {
76
93
  agentId: string;
77
- }): AgentCapabilities | undefined;
94
+ }, optional?: Record<string, never>): AgentCapabilities | undefined;
78
95
  }
96
+ /** Use declared CLI-resume capabilities, never a hand-kept agent list. Capture-style CLIs mint
97
+ * their own ids; handing one a host id would persist a session that does not exist. ACP-only
98
+ * resume uses session/load rather than CLI flags. Unknown agents fail closed. */
79
99
  export declare function agentAcceptsHostMintedSessionId({ agentId, agents }: {
80
100
  agentId: string;
81
101
  agents: AgentCapabilityResolver;
82
- }): boolean;
102
+ }, _optional?: Record<string, never>): boolean;
103
+ /** CLI/ACP-resuming agents carry prior turns and may receive only the newest user message.
104
+ * Resolve capabilities synchronously through the port; no PATH probing is needed. */
83
105
  export declare function agentCarriesOwnMemory({ agentId, agents }: {
84
106
  agentId: string;
85
107
  agents: AgentCapabilityResolver;
86
- }): boolean;
108
+ }, _optional?: Record<string, never>): boolean;
109
+ /** Mint only a cold, conversation-scoped start for a host-id-capable agent. Persist and pass the
110
+ * same id before spawning: relying only on a terminal report can orphan a crashed first turn.
111
+ * A resume already has a binding; replacing it would fork the conversation. */
87
112
  export declare function resolveHostMintedSessionId(input: {
88
113
  conversationId: string | undefined;
89
114
  effectiveResumeSessionId: string | null;
90
115
  acceptsHostMintedSessionId: boolean;
91
116
  mint: () => string;
92
- }): string | null;
117
+ }, _optional?: Record<string, never>): string | null;
118
+ /** Return { newSessionId } when present, otherwise omit the key entirely for the run-input spread. */
93
119
  export declare function resolveNewSessionField({ hostMintedSessionId }: {
94
120
  hostMintedSessionId: string | null;
95
- }): {
121
+ }, _optional?: Record<string, never>): {
96
122
  newSessionId?: string;
97
123
  };
124
+ /** Return { resumeSessionId } when present, otherwise omit the key entirely for the run-input spread. */
98
125
  export declare function resolveResumeSessionField({ storedSessionId }: {
99
126
  storedSessionId: string | null;
100
- }): {
127
+ }, _optional?: Record<string, never>): {
101
128
  resumeSessionId?: string;
102
129
  };
130
+ /** Only a terminal end with a nonempty sessionRef establishes a session id to persist. */
103
131
  export declare function extractSessionRefFromEndEvent({ event }: {
104
132
  event: RunProtocolEvent;
105
- }): string | undefined;
133
+ }, _optional?: Record<string, never>): string | undefined;
134
+ /** Clear only an attempted resume whose terminal event reconfirms no session. The event cannot
135
+ * distinguish a dead session from an unrelated early failure: one cold turn costs less than
136
+ * retrying an unrecoverable id forever. A cold run has no stored binding to clear. */
106
137
  export declare function shouldClearSessionOnFailedResume({ event, attemptedResumeSessionId }: {
107
138
  event: RunProtocolEvent;
108
139
  attemptedResumeSessionId: string | null;
109
- }): boolean;
140
+ }, _optional?: Record<string, never>): boolean;
141
+ /** Refuse a forced cold start when a stored session, concurrent peer and memory-carrying agent
142
+ * coincide: the client may have sent only the latest prompt, so cold execution loses prior turns.
143
+ * A first turn or an agent receiving the full transcript has no such context to lose. */
110
144
  export declare function wouldForcedColdStartLoseConversationContext(input: {
111
145
  storedSessionId: string | null;
112
146
  hasConcurrentLiveRun: boolean;
113
147
  carriesOwnMemory: boolean;
114
- }): boolean;
148
+ }, _optional?: Record<string, never>): boolean;
115
149
  //# sourceMappingURL=session-coordination.d.ts.map
@@ -1,4 +1,5 @@
1
- export function createLiveRunTracker(_args) {
1
+ /** Fresh private maps keep daemon instances independent. */
2
+ export function createLiveRunTracker(_args, _optional = {}) {
2
3
  const runs = new Map();
3
4
  const conversations = new Map();
4
5
  function unregister({ conversationId, runId }) {
@@ -30,16 +31,20 @@ export function createLiveRunTracker(_args) {
30
31
  conversationIdForRun: ({ runId }) => conversations.get(runId),
31
32
  };
32
33
  }
33
- /** Serialize initialization only. Run execution belongs outside the critical section. */
34
- export function createConversationStartLock(_args) {
34
+ /** Serialize the session-binding read/decide/write so simultaneous first turns cannot mint competing
35
+ * sessions and orphan a binding. Lock only initialization: holding it across execution would queue
36
+ * turns behind minutes of work instead of allowing an immediate concurrency refusal. In-process only. */
37
+ export function createConversationStartLock(_args, _optional = {}) {
35
38
  const tails = new Map();
36
39
  return {
37
40
  run({ conversationId, critical }) {
38
41
  if (conversationId === undefined)
39
42
  return critical();
40
43
  const result = (tails.get(conversationId) ?? Promise.resolve()).then(critical);
44
+ // A newer section may own this key already; deleting its tail would allow a third to race it.
41
45
  const release = () => { if (tails.get(conversationId) === tail)
42
46
  tails.delete(conversationId); };
47
+ // Both settlements release immediately and make the tail non-rejecting for the next section.
43
48
  const tail = result.then(release, release);
44
49
  tails.set(conversationId, tail);
45
50
  return result;
@@ -48,9 +53,13 @@ export function createConversationStartLock(_args) {
48
53
  };
49
54
  }
50
55
  export const STOPPING_RUN_WAIT_MS = 20_000;
56
+ /** Wait only for cancelled peers: cancellation records intent before the CLI exits. Active peers
57
+ * can run for minutes and stay subject to the concurrency rule. Remove ended peers here because
58
+ * ordering against their own terminal cleanup is not guaranteed. The wait is bounded by timeoutMs. */
51
59
  export async function waitForStoppingRuns({ tracker, lifecycle, scheduler, conversationId, runId }, { timeoutMs = STOPPING_RUN_WAIT_MS } = {}) {
52
60
  const stopping = tracker.concurrentLiveRunIds({ conversationId, runId }).filter(id => {
53
61
  let cancelled = false;
62
+ // Subscription replays a past cancellation synchronously; immediate unsubscribe avoids leaks.
54
63
  try {
55
64
  lifecycle.onCancelRequested({ runId: id, listener: () => { cancelled = true; } })();
56
65
  }
@@ -72,40 +81,59 @@ export async function waitForStoppingRuns({ tracker, lifecycle, scheduler, conve
72
81
  cancelTimer?.();
73
82
  }
74
83
  }
75
- export async function failRunBeforeStart({ lifecycle, runId, message }) {
84
+ /** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
85
+ * is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
86
+ export async function failRunBeforeStart({ lifecycle, runId, message }, _optional = {}) {
76
87
  try {
77
88
  await lifecycle.emit({ runId: runId, input: { event: 'error', data: { message } } });
78
89
  }
79
90
  catch { /* Finishing remains mandatory. */ }
80
91
  await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
81
92
  }
82
- export function agentAcceptsHostMintedSessionId({ agentId, agents }) {
93
+ /** Use declared CLI-resume capabilities, never a hand-kept agent list. Capture-style CLIs mint
94
+ * their own ids; handing one a host id would persist a session that does not exist. ACP-only
95
+ * resume uses session/load rather than CLI flags. Unknown agents fail closed. */
96
+ export function agentAcceptsHostMintedSessionId({ agentId, agents }, _optional = {}) {
83
97
  const def = agents.lookup({ agentId });
84
98
  return Boolean(def?.resumesSessionViaCli) && !def?.capturesSessionIdFromStream;
85
99
  }
86
- export function agentCarriesOwnMemory({ agentId, agents }) {
100
+ /** CLI/ACP-resuming agents carry prior turns and may receive only the newest user message.
101
+ * Resolve capabilities synchronously through the port; no PATH probing is needed. */
102
+ export function agentCarriesOwnMemory({ agentId, agents }, _optional = {}) {
87
103
  const def = agents.lookup({ agentId });
88
104
  return Boolean(def?.resumesSessionViaCli) || Boolean(def?.resumesSessionViaAcpLoad);
89
105
  }
90
- export function resolveHostMintedSessionId(input) {
106
+ /** Mint only a cold, conversation-scoped start for a host-id-capable agent. Persist and pass the
107
+ * same id before spawning: relying only on a terminal report can orphan a crashed first turn.
108
+ * A resume already has a binding; replacing it would fork the conversation. */
109
+ export function resolveHostMintedSessionId(input, _optional = {}) {
91
110
  return input.conversationId !== undefined && input.effectiveResumeSessionId === null && input.acceptsHostMintedSessionId ? input.mint() : null;
92
111
  }
93
- export function resolveNewSessionField({ hostMintedSessionId }) {
112
+ /** Return { newSessionId } when present, otherwise omit the key entirely for the run-input spread. */
113
+ export function resolveNewSessionField({ hostMintedSessionId }, _optional = {}) {
94
114
  return hostMintedSessionId !== null ? { newSessionId: hostMintedSessionId } : {};
95
115
  }
96
- export function resolveResumeSessionField({ storedSessionId }) {
116
+ /** Return { resumeSessionId } when present, otherwise omit the key entirely for the run-input spread. */
117
+ export function resolveResumeSessionField({ storedSessionId }, _optional = {}) {
97
118
  return storedSessionId !== null ? { resumeSessionId: storedSessionId } : {};
98
119
  }
99
- export function extractSessionRefFromEndEvent({ event }) {
120
+ /** Only a terminal end with a nonempty sessionRef establishes a session id to persist. */
121
+ export function extractSessionRefFromEndEvent({ event }, _optional = {}) {
100
122
  if (event.kind !== 'end')
101
123
  return undefined;
102
124
  const id = event.payload.sessionRef;
103
125
  return typeof id === 'string' && id.length > 0 ? id : undefined;
104
126
  }
105
- export function shouldClearSessionOnFailedResume({ event, attemptedResumeSessionId }) {
127
+ /** Clear only an attempted resume whose terminal event reconfirms no session. The event cannot
128
+ * distinguish a dead session from an unrelated early failure: one cold turn costs less than
129
+ * retrying an unrecoverable id forever. A cold run has no stored binding to clear. */
130
+ export function shouldClearSessionOnFailedResume({ event, attemptedResumeSessionId }, _optional = {}) {
106
131
  return attemptedResumeSessionId !== null && event.kind === 'end' && extractSessionRefFromEndEvent({ event }) === undefined;
107
132
  }
108
- export function wouldForcedColdStartLoseConversationContext(input) {
133
+ /** Refuse a forced cold start when a stored session, concurrent peer and memory-carrying agent
134
+ * coincide: the client may have sent only the latest prompt, so cold execution loses prior turns.
135
+ * A first turn or an agent receiving the full transcript has no such context to lose. */
136
+ export function wouldForcedColdStartLoseConversationContext(input, _optional = {}) {
109
137
  return input.storedSessionId !== null && input.hasConcurrentLiveRun && input.carriesOwnMemory;
110
138
  }
111
139
  //# sourceMappingURL=session-coordination.js.map
@@ -81,10 +81,10 @@ export function openSqliteEventLog(input, options = {}) {
81
81
  resolveMaxEntriesPerRun(options.maxEntriesPerRun);
82
82
  let db;
83
83
  try {
84
- db = openSqliteConnection({ filePath: input.file, open(file, settings) {
85
- db = input.open(file, settings);
84
+ db = openSqliteConnection({ filePath: input.file, open({ filePath }, settings) {
85
+ db = input.open(filePath, settings);
86
86
  return db;
87
- }, pragmas: ['journal_mode = WAL'] });
87
+ } }, { pragmas: ['journal_mode = WAL'] });
88
88
  const log = bindSqliteEventLog(db, options);
89
89
  let closed = false;
90
90
  return { ...log, async close(_args) { if (closed)
@@ -1,61 +1,99 @@
1
+ /**
2
+ * @file A **surface exchange**: a two-way, multi-message conversation with a human, held open inside
3
+ * one agent tool call.
4
+ *
5
+ * ## What this is
6
+ *
7
+ * The agent calls a tool. The tool opens an exchange, sends something for a person to look at, and
8
+ * waits. The person answers. The tool may send again — a follow-up question, an updated component
9
+ * tree, a validation error — and wait again, any number of times. When it is done it closes the
10
+ * exchange and returns, and the human's answers are the tool call's ordinary result. The agent is
11
+ * still alive the whole time, which is what makes the transcript correct by construction rather than
12
+ * reconstructed afterwards (ADR-055 Decision 4).
13
+ *
14
+ * A one-shot ask — show a form, get one submission — is the degenerate case, expressed by
15
+ * {@link askOnce}. It is a helper over this, not a separate mechanism.
16
+ *
17
+ * ## Channel-agnostic on purpose
18
+ *
19
+ * Nothing here knows what a surface *is*. Outbound messages are `@jini-ai/core`'s
20
+ * {@link SurfaceEmission}, which names its own channel; inbound answers are plain records. That is
21
+ * what lets one exchange drive MCP-UI (an HTML surface answering by tool call), A2UI (`createSurface`
22
+ * → action → `updateComponents` → action, which is inherently multi-turn), the run protocol's own
23
+ * channel-neutral `surface_request`/`surface_response` pair, or a paradigm that does not exist yet.
24
+ * The transports differ; "hold the agent's call open and pass messages both ways" does not.
25
+ *
26
+ * ## The inbound buffer is a correctness requirement, not a nicety
27
+ *
28
+ * A message can arrive while the handler is between {@link SurfaceExchange.receive} calls — busy
29
+ * composing its next send. Without somewhere to put it, that message is dropped and the exchange
30
+ * deadlocks on an answer the human already gave. So deliveries queue. This is the one thing that is
31
+ * genuinely painful to retrofit onto a one-shot store, which is why it is here before there is a
32
+ * multi-turn channel wired to need it.
33
+ *
34
+ * ## Opening requires an emitter, so the other deadlock is unrepresentable
35
+ *
36
+ * The second deadlock is structural: a handler waiting for an answer to a message it never managed
37
+ * to send. The daemon reads surfaces out of a *completed* tool result, so the return value cannot
38
+ * carry a message the same call is waiting on — only `ToolExecutionContext.emitSurface` can.
39
+ * {@link SurfaceExchangeStore.open} therefore takes that emitter as a required argument. A handler
40
+ * with no emitter cannot open an exchange at all, and must fail closed without an interactive channel.
41
+ *
42
+ * ## Not a secret
43
+ *
44
+ * An exchange id is a **correlation handle**. It says which in-flight call a message belongs to and
45
+ * confers nothing — contrast the former token store, which minted a genuine secret because the
46
+ * delete it guards is a second tool call the model could otherwise make itself (ADR-055 Decision 3).
47
+ * Ids here are stored in the clear and compared with `===`. Copying that module's hashing and
48
+ * constant-time comparison would imply a security property this handle does not carry.
49
+ *
50
+ * The property this file IS responsible for is narrower: **a message resolves at most one waiting
51
+ * receive, on an exchange opened by the same tool for the same principal.**
52
+ *
53
+ */
1
54
  import type { Clock, IdGenerator } from '@jini-ai/core/primitives';
2
- import type { SurfaceEmission, SurfaceEmitter } from '@jini-ai/core';
55
+ import type { SurfaceEmission, SurfaceAnswerChannel, SurfaceMessage, SurfaceExchangeConversation, SurfaceExchangeStore } from '@jini-ai/core';
56
+ export { SURFACE_EXCHANGE_ID_PARAM, SURFACE_DISMISSED_PARAM, SURFACE_TYPED_ANSWER_PARAM, } from '@jini-ai/core';
57
+ export type { SurfaceAnswerChannel, SurfaceExchangeBinding, SurfaceMessage, SurfaceDeliveryRejectionReason, DeliverResult, SurfaceExchange, SurfaceExchangeConversation, SurfaceDeliverySpec, SurfaceExchangeStore, SurfaceAskThenReport, } from '@jini-ai/core';
3
58
  import type { SchedulerPort } from './scheduler.js';
4
59
  export type { SchedulerPort } from './scheduler.js';
5
- export declare const SURFACE_EXCHANGE_ID_PARAM = "__exchangeId";
6
- export declare const SURFACE_DISMISSED_PARAM = "__dismissed";
7
- export declare const SURFACE_TYPED_ANSWER_PARAM = "__typedAnswer";
60
+ /**
61
+ * How long an exchange may sit with nothing happening before it gives up.
62
+ *
63
+ * Per *turn*, not per exchange: it resets whenever a message is sent or received, so a long
64
+ * multi-turn conversation is not punished for taking several turns — only for a human who walked
65
+ * away. Long enough to read a dialog and decide, short enough that an abandoned tab does not hold a
66
+ * live agent subprocess open.
67
+ */
8
68
  export declare const DEFAULT_SURFACE_IDLE_TTL_MS: number;
69
+ /**
70
+ * Hard ceiling on one exchange's total life, regardless of activity.
71
+ *
72
+ * Exists because the idle deadline alone is unbounded in aggregate — a human answering every four
73
+ * minutes could hold a call open indefinitely, and the call is not ours to hold: it is an HTTP
74
+ * request from the spawned agent's MCP server, which gives up after 6 minutes (`@jini-ai/mcp`'s
75
+ * delegated-tool deadline). Sized just inside that, so a stalled exchange returns an explicit result
76
+ * the model can read rather than having the transport time out underneath it.
77
+ *
78
+ * **This, not the idle deadline, is what caps a multi-turn conversation today.** Raising it means
79
+ * raising the transport deadline first; the two must move together or the ordering inverts.
80
+ */
9
81
  export declare const DEFAULT_SURFACE_MAX_LIFETIME_MS: number;
10
- export type SurfaceAnswerChannel = string;
11
- export interface SurfaceExchangeBinding {
12
- readonly toolId: string;
13
- readonly principalId: string;
14
- readonly channel?: SurfaceAnswerChannel;
15
- }
16
- export type SurfaceMessage = {
17
- status: 'received';
18
- params: Record<string, unknown>;
19
- } | {
20
- status: 'expired';
21
- } | {
22
- status: 'abandoned';
23
- };
24
- export type SurfaceDeliveryRejectionReason = 'unknown-or-closed' | 'binding-mismatch';
25
- export type DeliverResult = {
26
- ok: true;
27
- } | {
28
- ok: false;
29
- reason: SurfaceDeliveryRejectionReason;
30
- };
31
- export interface SurfaceExchange {
32
- readonly id: string;
33
- send(args: {
34
- emission: SurfaceEmission;
35
- }): Promise<void>;
36
- receive(_args: Record<string, never>): Promise<SurfaceMessage>;
37
- close(_args: Record<string, never>): void;
38
- }
39
- export interface SurfaceDeliverySpec {
40
- exchangeId: string;
41
- params: Record<string, unknown>;
42
- principalId: string;
43
- toolId?: string;
44
- channel?: SurfaceAnswerChannel;
45
- }
46
- export interface SurfaceExchangeStore {
47
- open(args: {
48
- binding: SurfaceExchangeBinding;
49
- emit: SurfaceEmitter;
50
- }): SurfaceExchange;
51
- deliver(requiredArgs: Pick<SurfaceDeliverySpec, "exchangeId" | "params" | "principalId">, optionalArgs?: Pick<SurfaceDeliverySpec, "toolId" | "channel">): DeliverResult;
52
- findTypedAnswerTarget(args: {
53
- principalId: string;
54
- toolId: string;
55
- }): string | undefined;
56
- size(): number;
57
- }
58
- /** In-process held calls. Inject a scheduler that unrefs its timers where appropriate. */
82
+ /**
83
+ * Creates an in-process store of open surface exchanges.
84
+ *
85
+ * In-process necessarily, not merely by choice: an open exchange is a suspended function call in
86
+ * this process's memory. It could not be shared across processes even if that were desirable.
87
+ * `mcp-ui-tool-calls-route.ts` is mounted on the same daemon the handlers run in, which is what makes
88
+ * delivery a direct call rather than another network hop.
89
+ *
90
+ * @param options.idleTtlMs - Per-turn inactivity deadline. See {@link DEFAULT_SURFACE_IDLE_TTL_MS}.
91
+ * @param options.maxLifetimeMs - Total lifetime ceiling. See {@link DEFAULT_SURFACE_MAX_LIFETIME_MS}.
92
+ * @param deps.idGenerator - Id source, injected so a test can assert a known value.
93
+ * @param deps.clock - Wall clock for {@link SurfaceExchange.expiresAtMs}. The injected scheduler owns
94
+ * the timers; the clock dates and checks their deadlines.
95
+ * @complexity O(1) per send/receive/deliver. Each exchange owns two `unref`'d timers.
96
+ */
59
97
  export declare function createSurfaceExchangeStore(deps: {
60
98
  scheduler: SchedulerPort;
61
99
  clock: Clock;
@@ -65,33 +103,141 @@ export declare function createSurfaceExchangeStore(deps: {
65
103
  idleTtlMs?: number;
66
104
  maxLifetimeMs?: number;
67
105
  }): SurfaceExchangeStore;
106
+ /**
107
+ * The one-shot case: send once, wait once, close.
108
+ *
109
+ * Kept as a helper rather than a second mechanism, because "ask a question and get one answer" is
110
+ * genuinely just the shortest exchange. Anything that later needs a follow-up turn stops calling this
111
+ * and drives the exchange directly, with no change to the store, the route, or the transport.
112
+ */
68
113
  export declare function askOnce({ exchange, emission }: {
69
- exchange: SurfaceExchange;
114
+ exchange: SurfaceExchangeConversation;
70
115
  emission: SurfaceEmission;
71
- }): Promise<SurfaceMessage>;
116
+ }, _optional?: Record<string, never>): Promise<SurfaceMessage>;
117
+ /**
118
+ * Like {@link askOnce}, but for a call whose answer triggers real work whose OUTCOME the human also
119
+ * needs to see — a publish, a delete, a credential save. `askOnce` alone cannot express this: it
120
+ * closes the exchange the instant an answer arrives, so a caller that awaited it and then tried to
121
+ * `exchange.send()` a result would find the exchange already dead (`send()` throws "already ended").
122
+ *
123
+ * ## The defect this exists to fix
124
+ *
125
+ * A confirmation surface's own script (`confirmation.ts`) sets its status text to "Done." the moment
126
+ * the confirming `tools/call` RESOLVES — and for a held-open exchange delivered through
127
+ * `mcp-ui-tool-calls-route.ts`'s Shape 1, that call resolves the instant the click is DELIVERED to
128
+ * the parked agent call (`{delivered: true}`, HTTP 202), which happens before the parked handler has
129
+ * done anything with the answer yet, let alone finished. "Done." therefore means "your click
130
+ * reached the server," never "the thing you asked for actually happened" — a 404, a rejected
131
+ * credential, and a genuine success all render the identical "Done." to the human. `handle` below is
132
+ * where a caller does the real work and decides what actually happened; the outcome emission this
133
+ * function sends afterward is what corrects the record.
134
+ *
135
+ * ## How the correction reaches the screen
136
+ *
137
+ * The outcome emission is expected to reuse the SAME `ui://` URI the confirmation was sent under.
138
+ * `McpUiSurfaceCard` (`@jini-ai/chat`) already collapses a stream to the newest resource per URI —
139
+ * "a stream may re-send an updated document for a surface already on screen (a confirmation that
140
+ * became a result), so the LAST event for each URI wins" (that component's own doc) — and
141
+ * `McpUiHost` keys its iframe by `` `${uri}:${documentText}` ``, so a new document forces a clean
142
+ * remount rather than leaving the confirmation's stale Confirm/Cancel buttons on screen. Neither of
143
+ * those needed a change for this to work; this function only needed to make the SECOND send possible.
144
+ *
145
+ * ## Why `handle` returns the outcome rather than the caller sending it separately
146
+ *
147
+ * Keeping the send here, not in the caller, is what makes the try/catch below unconditional: a
148
+ * caller could forget to wrap its own `exchange.send()`, but every caller of this function gets the
149
+ * protection for free. `outcome` is OPTIONAL in the return — a cancelled/expired/abandoned answer has
150
+ * nothing to correct (the confirmation's own script already reports "Dismissed." for a local cancel,
151
+ * and an exchange that ended via a timeout is already closed, so `send()` would throw anyway); a
152
+ * caller for that branch simply omits it.
153
+ *
154
+ * ## The one invariant this function protects
155
+ *
156
+ * `result` — what reaches the model — must NEVER depend on the outcome emission actually reaching a
157
+ * screen. A human who closed the tab, a `send()` racing a teardown, or any other failure in the
158
+ * human-visible half must be invisible to the tool's own JSON contract, which is why the outcome send
159
+ * is wrapped in its own `try`/`catch` rather than sharing `handle`'s.
160
+ *
161
+ * @param exchange - Opened the same way `askOnce` expects one.
162
+ * @param confirmationEmission - Sent once, exactly like `askOnce`'s own `emission` parameter.
163
+ * @param handle - Runs after the answer arrives (whatever it is — received, expired, abandoned).
164
+ * Does the caller's real work and returns `result` (returned to THIS function's own caller,
165
+ * ultimately the model) plus an optional `outcome` emission to report back to the human.
166
+ * @complexity O(1) plus whatever `handle` itself costs — this function adds one conditional send and
167
+ * the same `close()` `askOnce` already pays.
168
+ */
72
169
  export declare function askThenReport<T>({ exchange, confirmationEmission, handle }: {
73
- exchange: SurfaceExchange;
170
+ exchange: SurfaceExchangeConversation;
74
171
  confirmationEmission: SurfaceEmission;
75
172
  handle: (answer: SurfaceMessage) => Promise<{
76
173
  result: T;
77
174
  outcome?: SurfaceEmission;
78
175
  }>;
79
- }): Promise<T>;
176
+ }, _optional?: Record<string, never>): Promise<T>;
177
+ /**
178
+ * Whether the human confirmed, declined, or never answered at all — the fail-closed classification
179
+ * every confirm/cancel-shaped tool call needs (a delete, a commit, a publish, ...). Extracted after
180
+ * this exact logic had been copy-pasted at least 4 times (`features/post/tool-registrations.ts`'s
181
+ * `resolveDeleteDecision`, then `features/custom-credentials`, `features/source-control`, and
182
+ * `features/deployments/publish-agent-tools.ts`, each self-described as mirroring the first), with
183
+ * the SAME "fail closed, never read a missing/malformed decision as consent" fix required at every
184
+ * site (see `classifyConfirmationAnswer`'s own doc).
185
+ *
186
+ * `"declined"` covers an explicit non-`"confirm"` decision — a real Cancel click, and equally a
187
+ * missing, non-string, or unrecognised `decision` field, all fail-closed to the same outcome.
188
+ * `"expired"`/`"abandoned"` mirror {@link SurfaceMessage}'s own non-`"received"` statuses one-for-one
189
+ * (ADR-055 Decision 6: no-answer is a result, not a thrown error).
190
+ */
80
191
  export type ConfirmationOutcome = {
81
192
  confirmed: true;
82
193
  } | {
83
194
  confirmed: false;
84
195
  reason: 'declined' | 'expired' | 'abandoned';
85
196
  };
86
- /** Typed prose is never consent; only an explicit literal decision grants approval. */
197
+ /**
198
+ * Classifies an already-received {@link SurfaceMessage} as confirmed or not.
199
+ *
200
+ * Pure and synchronous on purpose: a caller that already holds an `answer` from somewhere other than
201
+ * a fresh {@link askOnce} call — `askThenReport`'s own `handle` callback, for instance, which receives
202
+ * the message directly because a second `exchange.send()` still needs to happen afterward — can
203
+ * classify it without a second round trip. {@link resolveConfirmationDecision} below is the `askOnce`
204
+ * convenience built on top of this for callers that do not need that.
205
+ *
206
+ * Fail-closed is the entire contract: only a `params.decision` field that is literally the string
207
+ * `"confirm"` proceeds. A missing field, a non-string value, or any other string must never be read
208
+ * as consent for a destructive or externally-visible action.
209
+ *
210
+ * @complexity O(1).
211
+ */
87
212
  export declare function classifyConfirmationAnswer({ answer }: {
88
213
  answer: SurfaceMessage;
89
- }): ConfirmationOutcome;
214
+ }, _optional?: Record<string, never>): ConfirmationOutcome;
215
+ /**
216
+ * {@link askOnce} plus {@link classifyConfirmationAnswer} — the whole "ask, then fail-closed classify"
217
+ * sequence every confirm/cancel-shaped delete/commit/publish tool repeats. A caller still owns its own
218
+ * domain-specific result shape for each branch (see e.g. `features/post/tool-registrations.ts`'s own
219
+ * `resolveDeleteDecision`) — this only shares the parked-ask-and-classify mechanics, not the result,
220
+ * so each site's `deleted`/`committed`/`published`/... wording stays exactly what it was.
221
+ *
222
+ * @complexity O(1) plus whatever {@link askOnce} costs.
223
+ */
90
224
  export declare function resolveConfirmationDecision(args: {
91
- exchange: SurfaceExchange;
225
+ exchange: SurfaceExchangeConversation;
92
226
  emission: SurfaceEmission;
93
- }): Promise<ConfirmationOutcome>;
227
+ }, _optional?: Record<string, never>): Promise<ConfirmationOutcome>;
228
+ /**
229
+ * Cross-domain machinery a surface-raising tool needs that is not a domain dependency at all.
230
+ *
231
+ * Kept separate from `tool-registrations.ts`'s `AssistantToolRegistryDeps` on purpose: that bag is
232
+ * "the same dependencies the admin HTTP routes are built from", which is what makes a tool call and
233
+ * the equivalent human click reach identical domain code. An exchange store is not part of that
234
+ * equivalence — it belongs to the assistant's transport, and only tools that talk to a human mid-call
235
+ * ever touch it. Declared in this module rather than alongside the registration wiring so a tool can
236
+ * name it without importing the file that imports the tool.
237
+ */
94
238
  export interface AssistantSurfaceDeps {
239
+ /** The handlers and callback route must share this same in-process store. Opening in one store
240
+ * and delivering to another leaves the human's answer unreachable until the deadline expires. */
95
241
  readonly surfaceExchanges: SurfaceExchangeStore;
96
242
  }
97
243
  //# sourceMappingURL=surface-exchanges.d.ts.map