@jini-ai/daemon 0.6.15 → 0.6.17

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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +14 -4
  3. package/dist/agent-executor/codex/isolation.js +2 -3
  4. package/dist/agent-executor/contracts.d.ts +1 -1
  5. package/dist/agent-executor/contracts.js +0 -1
  6. package/dist/agent-executor/event-translation.d.ts +3 -3
  7. package/dist/agent-executor/event-translation.js +4 -4
  8. package/dist/agent-executor/events.js +1 -3
  9. package/dist/agent-executor/index.d.ts +5 -5
  10. package/dist/agent-executor/index.js +5 -5
  11. package/dist/ai-trace-lifecycle.d.ts +1 -1
  12. package/dist/ai-trace-lifecycle.js +1 -1
  13. package/dist/core/tool-audit.js +11 -7
  14. package/dist/frontend-capability-tools.d.ts +2 -0
  15. package/dist/frontend-capability-tools.js +7 -1
  16. package/dist/frontend-session-registry.d.ts +42 -3
  17. package/dist/frontend-session-registry.js +61 -9
  18. package/dist/http/frontend-control.d.ts +13 -0
  19. package/dist/http/frontend-control.js +14 -3
  20. package/dist/http/frontend-guest-scope.d.ts +32 -0
  21. package/dist/http/frontend-guest-scope.js +63 -0
  22. package/dist/http/frontend-sessions.d.ts +29 -6
  23. package/dist/http/frontend-sessions.js +106 -28
  24. package/dist/http/index.d.ts +1 -0
  25. package/dist/http/index.js +1 -0
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +2 -2
  28. package/dist/store/event-log/sqlite-kernel.js +2 -1
  29. package/dist/terminal-session.d.ts +51 -20
  30. package/dist/terminal-session.js +6 -89
  31. package/dist/terminal.adapter.d.ts +22 -0
  32. package/dist/terminal.adapter.js +47 -0
  33. package/dist/tool-executor.d.ts +4 -2
  34. package/dist/tool-executor.js +50 -14
  35. package/dist/tool-result-media.d.ts +4 -0
  36. package/dist/tool-result-media.js +3 -2
  37. package/package.json +29 -38
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 0.6.17
2
+
3
+ ### Patch Changes
4
+
5
+ - Handle npm 12 pack JSON through the shared parser in packed agent-session fixtures.
6
+
7
+ ## 0.6.16
8
+
9
+ ### Minor Changes
10
+
11
+ - Remove the GitHub Copilot CLI agent: its def, `createCopilotStreamHandler`/`CopilotStreamEvent`,
12
+ account model discovery and the `@github/copilot-sdk` optional peer, the MCP installer's `copilot`
13
+ slug, and its icon. A saved `copilot` agent id no longer resolves (`getAgentDef` returns `null`).
14
+ CopilotKit/AG-UI is unrelated and unchanged.
15
+
16
+ Drop peers no runtime entry loads: `express` from core and http-kit (type-only imports; type
17
+ consumers keep `@types/express`), `vue` from ui-kit, `react-dom` from vibecoding.
18
+
1
19
  ## 0.6.13
2
20
 
3
21
  ### Patch Changes
package/README.md CHANGED
@@ -20,8 +20,18 @@ npm install @jini-ai/daemon
20
20
 
21
21
  `node-pty` is an **optional peer dependency** — the package boots and every other capability
22
22
  (run lifecycle, tool executor, agent executor, routines) works without it. Only
23
- `createTerminalSessionManager`'s real (non-test) `PtySpawn` — an actual interactive terminal/PTY
24
- spawn — needs `node-pty` installed; everything else has no native-addon dependency at all.
23
+ the native adapter at `@jini-ai/daemon/terminal` needs `node-pty` installed.
24
+ The default entry's session manager requires a host-supplied `terminalService` or `loadSpawnPty`:
25
+
26
+ ```ts
27
+ import { createTerminalSessionManager } from '@jini-ai/daemon';
28
+ import { loadRealSpawnPty } from '@jini-ai/daemon/terminal';
29
+
30
+ const manager = createTerminalSessionManager({}, { loadSpawnPty: () => loadRealSpawnPty({}) });
31
+ ```
32
+
33
+ With `@jini-ai/server`, pass that loader as `featureOptions.terminal.loadSpawnPty` to
34
+ `composeJiniKernel`, or as `loadTerminalSpawnPty` to `createLocalNodeDaemon`.
25
35
 
26
36
  ## What you get
27
37
 
@@ -47,8 +57,8 @@ spawn — needs `node-pty` installed; everything else has no native-addon depend
47
57
  browser surface) and `createFrontendCapabilityRegistrations` (projects a frontend capability
48
58
  manifest into `ToolRegistry` registrations — the only door into it).
49
59
  - **Terminal sessions** — `createTerminalSessionManager`, `createTerminalToolRegistrations` — a
50
- `node-pty`-backed session manager built on `@jini-ai/platform`'s generic ring-buffer engine, plus
51
- session-token gating.
60
+ session manager built on `@jini-ai/platform`'s generic ring-buffer engine, plus session-token
61
+ gating. The optional native `loadRealSpawnPty` adapter lives at `@jini-ai/daemon/terminal`.
52
62
  - **Routines** — `RoutineService`, `createInMemoryRoutineStore` (`routines/`) — a DST-safe
53
63
  wall-clock scheduler plus CRUD + run-history store port.
54
64
  - **Run-orchestration helpers** (`run/`) — `runResultFromStatus`, `deriveRunErrorCode`,
@@ -1,4 +1,4 @@
1
- import { createRequire } from 'node:module';
1
+ import { parse } from 'smol-toml';
2
2
  import { JINI_MCP_SERVER_KEY } from '../mcp-bridge.js';
3
3
  import { isRecord } from '../values.js';
4
4
  /** A run with personal MCP off that Codex cannot keep to the bridge alone: refused, never run with a leak. */
@@ -16,8 +16,7 @@ export function codexIsolationRefusalMessage(reason) {
16
16
  * @complexity O(n) in the file length.
17
17
  */
18
18
  function parseCodexLayer({ raw, path }, _optional = {}) {
19
- // The optional parser is needed only for Codex isolation, so memory-only hosts can still boot.
20
- const { parse } = createRequire(import.meta.url)('smol-toml');
19
+ // Codex isolation is a default security boundary; its parser must ship with the daemon.
21
20
  try {
22
21
  return parse(raw);
23
22
  }
@@ -23,7 +23,7 @@ export type StreamHandler = {
23
23
  }): void;
24
24
  flush(): void;
25
25
  };
26
- export declare const SUPPORTED_STREAM_FORMATS: readonly ["claude-stream-json", "json-event-stream", "copilot-stream-json", "qoder-stream-json", "acp-json-rpc", "pi-rpc", "plain"];
26
+ export declare const SUPPORTED_STREAM_FORMATS: readonly ["claude-stream-json", "json-event-stream", "qoder-stream-json", "acp-json-rpc", "pi-rpc", "plain"];
27
27
  /** The families of the registry's stream-format this driver implements — see index.ts module doc. */
28
28
  export type SupportedStreamFormat = (typeof SUPPORTED_STREAM_FORMATS)[number];
29
29
  export type JsonStreamFormat = Exclude<SupportedStreamFormat, 'acp-json-rpc' | 'pi-rpc' | 'plain'>;
@@ -1,7 +1,6 @@
1
1
  export const SUPPORTED_STREAM_FORMATS = [
2
2
  'claude-stream-json',
3
3
  'json-event-stream',
4
- 'copilot-stream-json',
5
4
  'qoder-stream-json',
6
5
  'acp-json-rpc',
7
6
  'pi-rpc',
@@ -35,9 +35,9 @@ export declare function translateTurnEndEvent({ rawEvent }: {
35
35
  * assertable in isolation.
36
36
  *
37
37
  * Defensive by construction: several real parser emissions carry fields
38
- * looser than `RunAgentPayload`'s types promise (e.g. copilot's
39
- * `tool.execution_start` emits `id: data.toolCallId ?? null` — a literal
40
- * `null`, not the `string` `RunAgentPayload['tool_use']['id']` demands).
38
+ * looser than `RunAgentPayload`'s types promise (e.g. an untrusted
39
+ * tool event can carry a literal `null`, not the `string`
40
+ * `RunAgentPayload['tool_use']['id']` demands).
41
41
  * Every field read here is defensively coerced (`asString`/
42
42
  * `asOptionalString`/`asOptionalNumber`) rather than trusted, so a
43
43
  * malformed or null field degrades to a safe default instead of
@@ -26,7 +26,7 @@ export function extractUsageTokens({ rawUsage }, _options = {}) {
26
26
  * fields beyond the basic counts — `thought_tokens`,
27
27
  * `cached_read_tokens`/`cached_write_tokens` (opencode/gemini/codex),
28
28
  * `modelUsage`/`stopReason`/`isError` (qoder), a top-level `stopReason`
29
- * (claude/copilot). Cache/reasoning counters and stop reasons now survive tracing; vendor-only
29
+ * (claude). Cache/reasoning counters and stop reasons now survive tracing; vendor-only
30
30
  * `modelUsage` and `isError` remain outside this neutral usage contract.
31
31
  * @param rawEvent - The raw `{type:'usage', ...}` record from a stream parser.
32
32
  * @returns The narrowed `RunAgentPayload` `usage` variant.
@@ -164,9 +164,9 @@ const EVENT_TYPE_TRANSLATORS = {
164
164
  * assertable in isolation.
165
165
  *
166
166
  * Defensive by construction: several real parser emissions carry fields
167
- * looser than `RunAgentPayload`'s types promise (e.g. copilot's
168
- * `tool.execution_start` emits `id: data.toolCallId ?? null` — a literal
169
- * `null`, not the `string` `RunAgentPayload['tool_use']['id']` demands).
167
+ * looser than `RunAgentPayload`'s types promise (e.g. an untrusted
168
+ * tool event can carry a literal `null`, not the `string`
169
+ * `RunAgentPayload['tool_use']['id']` demands).
170
170
  * Every field read here is defensively coerced (`asString`/
171
171
  * `asOptionalString`/`asOptionalNumber`) rather than trusted, so a
172
172
  * malformed or null field degrades to a safe default instead of
@@ -7,7 +7,7 @@ import { createModelReceiptTracker } from './model-receipts.js';
7
7
  import { observeRunOutput } from './run-output.js';
8
8
  import { messageContentWithImages } from '../attachment-content.js';
9
9
  import { redactSecrets, } from '@jini-ai/core';
10
- import { createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, attachAcpSession, attachPiRpcSession, } from '@jini-ai/agent-runtime';
10
+ import { createClaudeStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, attachAcpSession, attachPiRpcSession, } from '@jini-ai/agent-runtime';
11
11
  import { listProcessSnapshots, stopProcesses, } from '@jini-ai/platform/process';
12
12
  import { classifyRunCloseStatus, } from '../close-status.js';
13
13
  import { resolveContinuationTransport, } from '../continuation/continuation-transport.js';
@@ -42,8 +42,6 @@ function createStreamHandlerForDef(def, streamFormat, onEvent) {
42
42
  switch (streamFormat) {
43
43
  case 'claude-stream-json':
44
44
  return createClaudeStreamHandler({ onEvent: onEvent }, { costBasis: def.usageCostBasis ?? 'run' });
45
- case 'copilot-stream-json':
46
- return createCopilotStreamHandler({ onEvent: onEvent });
47
45
  case 'qoder-stream-json':
48
46
  return createQoderStreamHandler({ onEvent: onEvent });
49
47
  case 'json-event-stream':
@@ -11,12 +11,12 @@
11
11
  *
12
12
  * ## v1 scope: all 24 registered agent defs
13
13
  *
14
- * `@jini-ai/agent-runtime`'s registry ships 24 built-in defs across four
15
- * `streamFormat` families. The JSON-stream-parser family — the four
14
+ * `@jini-ai/agent-runtime`'s registry ships built-in defs across four
15
+ * `streamFormat` families. The JSON-stream-parser family — the three
16
16
  * `createXStreamHandler`-shaped parsers (`claude-stream-json`,
17
- * `json-event-stream`, `copilot-stream-json`, `qoder-stream-json`), covering
18
- * 9 defs (amp, codebuddy, claude, codex, cursor-agent, opencode, mimo,
19
- * copilot, qoder) — plus all 9 `acp-json-rpc` defs, plus the one `pi-rpc`
17
+ * `json-event-stream`, `qoder-stream-json`), covering
18
+ * 8 defs (amp, codebuddy, claude, codex, cursor-agent, opencode, mimo,
19
+ * qoder) — plus all 9 `acp-json-rpc` defs, plus the one `pi-rpc`
20
20
  * def (`pi`), are wired here. ACP and pi-rpc each own their own JSON-RPC
21
21
  * prompt-delivery protocol, so each takes its own lifecycle branch rather
22
22
  * than being treated as a stdout-tail parser; pi-rpc's events arrive through
@@ -11,12 +11,12 @@
11
11
  *
12
12
  * ## v1 scope: all 24 registered agent defs
13
13
  *
14
- * `@jini-ai/agent-runtime`'s registry ships 24 built-in defs across four
15
- * `streamFormat` families. The JSON-stream-parser family — the four
14
+ * `@jini-ai/agent-runtime`'s registry ships built-in defs across four
15
+ * `streamFormat` families. The JSON-stream-parser family — the three
16
16
  * `createXStreamHandler`-shaped parsers (`claude-stream-json`,
17
- * `json-event-stream`, `copilot-stream-json`, `qoder-stream-json`), covering
18
- * 9 defs (amp, codebuddy, claude, codex, cursor-agent, opencode, mimo,
19
- * copilot, qoder) — plus all 9 `acp-json-rpc` defs, plus the one `pi-rpc`
17
+ * `json-event-stream`, `qoder-stream-json`), covering
18
+ * 8 defs (amp, codebuddy, claude, codex, cursor-agent, opencode, mimo,
19
+ * qoder) — plus all 9 `acp-json-rpc` defs, plus the one `pi-rpc`
20
20
  * def (`pi`), are wired here. ACP and pi-rpc each own their own JSON-RPC
21
21
  * prompt-delivery protocol, so each takes its own lifecycle branch rather
22
22
  * than being treated as a stdout-tail parser; pi-rpc's events arrive through
@@ -2,7 +2,7 @@
2
2
  * @module ai-trace-lifecycle
3
3
  *
4
4
  * Wraps a {@link RunLifecycle} so every run it drives is also recorded as an AI trace. This is
5
- * the single tap for every provider path: child stream parsers (Claude Code, Copilot, Qoder,
5
+ * the single tap for every provider path: child stream parsers (Claude Code, Qoder,
6
6
  * Codex/OpenCode/Cursor/Kimi JSON streams), ACP sessions and pi-rpc all publish their normalized
7
7
  * events through `lifecycle.emit({ event: 'agent' })`. Observing the committed stream also sees
8
8
  * watchdog-driven terminal transitions, which call the base lifecycle's closed-over `finish()`.
@@ -2,7 +2,7 @@
2
2
  * @module ai-trace-lifecycle
3
3
  *
4
4
  * Wraps a {@link RunLifecycle} so every run it drives is also recorded as an AI trace. This is
5
- * the single tap for every provider path: child stream parsers (Claude Code, Copilot, Qoder,
5
+ * the single tap for every provider path: child stream parsers (Claude Code, Qoder,
6
6
  * Codex/OpenCode/Cursor/Kimi JSON streams), ACP sessions and pi-rpc all publish their normalized
7
7
  * events through `lifecycle.emit({ event: 'agent' })`. Observing the committed stream also sees
8
8
  * watchdog-driven terminal transitions, which call the base lifecycle's closed-over `finish()`.
@@ -49,17 +49,21 @@ function thrownAttempt({ error }, _optional = {}) {
49
49
  return { phase: /unknown tool/i.test(error.message) ? 'unknown-tool' : 'failed', detail: error.name };
50
50
  }
51
51
  /** Settled details retain truncation or an errorId link, never error text or operator content. */
52
- function settledDetail({ result, readErrorId }, options) {
53
- if (result.truncated)
54
- return 'output truncated';
52
+ function settledDetail({ result, readErrorId, readAuditRecord }, options) {
53
+ let detail = result.truncated ? 'output truncated' : null;
55
54
  // Host error-ID metadata is optional observation, never part of execution success.
56
55
  try {
57
- const id = readErrorId({ result });
58
- return id === undefined ? null : `errorId=${id}`;
56
+ const id = result.truncated ? undefined : readErrorId({ result });
57
+ if (id !== undefined)
58
+ detail = `errorId=${id}`;
59
+ const decision = readAuditRecord()?.events.find(event => event.detail?.startsWith('frontend approval: '))?.detail;
60
+ // A frontend may supply inputs, but only these fixed outcomes belong in the durable trail.
61
+ const approval = ['approved', 'declined', 'expired', 'abandoned'].map(value => `frontend approval: ${value}`).find(value => value === decision);
62
+ return [detail, approval].filter(Boolean).join('; ') || null;
59
63
  }
60
64
  catch (error) {
61
65
  reportSinkError(error, options);
62
- return null;
66
+ return detail;
63
67
  }
64
68
  }
65
69
  /** Append requested BEFORE delegation: unknown tools and throwing authorization have no execution
@@ -83,7 +87,7 @@ export function withToolAttemptAudit(deps, options = {}) {
83
87
  await appendSafely({ ...base, executionId: null, phase, at: deps.now(), detail }, deps, options);
84
88
  throw error;
85
89
  }
86
- const detail = settledDetail({ result, readErrorId: deps.readErrorId }, options);
90
+ const detail = settledDetail({ result, readErrorId: deps.readErrorId, readAuditRecord: () => deps.inner.getAuditRecord({ executionId: result.executionId }) }, options);
87
91
  await appendSafely({ ...base, executionId: result.executionId, phase: result.status, at: deps.now(), detail }, deps, options);
88
92
  return result;
89
93
  },
@@ -58,6 +58,8 @@ export interface FrontendCapabilitySpec {
58
58
  readonly description: string;
59
59
  /** Surfaced on the descriptor so `ToolExecutor`'s confirmation gate enforces it. */
60
60
  readonly requiresConfirmation?: boolean;
61
+ /** Protected page calls require a browser that supplies the human dialog channel. */
62
+ readonly requiresFrontendApproval?: boolean;
61
63
  /** Carried onto `ToolDescriptor.inputSchema` so a discovery surface can state the arguments. */
62
64
  readonly inputSchema?: unknown;
63
65
  }
@@ -63,7 +63,13 @@ export function createFrontendCapabilityRegistrations(requiredArgs, optionalArgs
63
63
  policy,
64
64
  // `ctx.signal` is `ToolExecutor`'s own timeout/cancel signal, so the descriptor's `timeoutMs`
65
65
  // and a cancelled run both reach the pending invocation without this module owning a timer.
66
- handler: async (ctx) => registry.invoke({ runId: ctx.run.id, capabilityId: capability.id, input: toCapabilityInput(capability.id, ctx.input) }, { signal: ctx.signal }),
66
+ handler: async (ctx, options) => {
67
+ // page.approval advertises the bound tab's human dialog, not an executable tool.
68
+ if (capability.requiresFrontendApproval && registry.sessionFor({ runId: ctx.run.id }) && !registry.capabilitiesFor({ runId: ctx.run.id }).includes('page.approval')) {
69
+ throw new Error('Attached frontend has no page approval dialog; reload the page.');
70
+ }
71
+ return registry.invoke({ runId: ctx.run.id, capabilityId: capability.id, input: toCapabilityInput(capability.id, ctx.input) }, { signal: ctx.signal, ...(options?.onApprovalStateChange ? { onApprovalStateChange: options.onApprovalStateChange } : {}) });
72
+ },
67
73
  }));
68
74
  }
69
75
  //# sourceMappingURL=frontend-capability-tools.js.map
@@ -1,7 +1,22 @@
1
+ import type { ToolApprovalDecision, ToolExecutionOptions } from '@jini-ai/core';
2
+ /** Host-authenticated ownership, never derived from a model/run context blob. */
3
+ export interface FrontendSessionOwner {
4
+ readonly workspaceId: string;
5
+ readonly principalId: string;
6
+ readonly tabId: string;
7
+ }
8
+ /** Shared owner comparison for attachment, binding and human-only reply gates. */
9
+ export declare function sameFrontendOwner({ expected: a, actual: b }: {
10
+ expected: FrontendSessionOwner | undefined;
11
+ actual: FrontendSessionOwner | undefined;
12
+ }, _options?: {}): boolean;
1
13
  /** A surface that has attached itself and can execute capabilities on a bound run's behalf. */
2
14
  export interface FrontendSessionDescriptor {
3
15
  /** Identifies this surface. Minted by the host at attach time; opaque here. */
4
16
  readonly sessionId: string;
17
+ readonly owner?: FrontendSessionOwner;
18
+ /** Host-authenticated quota scopes (e.g. guest and trusted client IP), never browser claims. */
19
+ readonly capacityKeys?: readonly string[];
5
20
  /**
6
21
  * Capability ids this surface claims it can execute. A call for anything this surface does not
7
22
  * claim fails closed rather than hanging.
@@ -43,6 +58,12 @@ export interface FrontendSessionHandle {
43
58
  detach(_args: Record<string, never>): void;
44
59
  }
45
60
  export interface FrontendSessionRegistry {
61
+ /** HTTP response authentication; possession of a URL session id grants nothing. */
62
+ ownsSession(args: {
63
+ sessionId: string;
64
+ owner: FrontendSessionOwner;
65
+ bindToken: string;
66
+ }): boolean;
46
67
  /**
47
68
  * Registers a surface and the function that pushes invocations to it (an SSE write, a WebSocket
48
69
  * send, an in-process call for a browser-resident caller that needs no relay at all).
@@ -52,6 +73,9 @@ export interface FrontendSessionRegistry {
52
73
  attach(args: {
53
74
  readonly descriptor: FrontendSessionDescriptor;
54
75
  readonly deliver: (invocation: FrontendInvocation) => void;
76
+ readonly cancel?: (args: {
77
+ invocationId: string;
78
+ }) => void;
55
79
  }): FrontendSessionHandle;
56
80
  /**
57
81
  * Associates a started run with the surface that originated it, so capability calls made by that
@@ -68,6 +92,13 @@ export interface FrontendSessionRegistry {
68
92
  readonly runId: string;
69
93
  readonly sessionId: string;
70
94
  }): () => void;
95
+ /** Acknowledges human-wait progress only for a still-pending invocation on this session. */
96
+ progress(args: {
97
+ sessionId: string;
98
+ invocationId: string;
99
+ pending: boolean;
100
+ decision?: ToolApprovalDecision;
101
+ }): boolean;
71
102
  /**
72
103
  * Binds a run to whichever surface holds `bindToken` — the wire-safe form of {@link bindRun}.
73
104
  *
@@ -86,6 +117,7 @@ export interface FrontendSessionRegistry {
86
117
  bindRunByToken(args: {
87
118
  readonly runId: string;
88
119
  readonly bindToken: string;
120
+ readonly owner?: FrontendSessionOwner;
89
121
  }): () => void;
90
122
  /**
91
123
  * Routes one capability call to the surface bound to `runId` and resolves with its output.
@@ -99,6 +131,7 @@ export interface FrontendSessionRegistry {
99
131
  readonly input: Record<string, unknown>;
100
132
  }, optionalArgs?: {
101
133
  readonly signal?: AbortSignal;
134
+ readonly onApprovalStateChange?: ToolExecutionOptions["onApprovalStateChange"];
102
135
  }): Promise<unknown>;
103
136
  /**
104
137
  * Settles a pending invocation with the surface's answer.
@@ -125,6 +158,8 @@ export interface FrontendSessionRegistry {
125
158
  }): FrontendSessionDescriptor | undefined;
126
159
  }
127
160
  export interface CreateFrontendSessionRegistryOptions {
161
+ readonly maxSessions?: number;
162
+ readonly maxSessionsPerKey?: number;
128
163
  /** Injectable id source for invocation ids — defaults to `randomUUID`. Test-only hook. */
129
164
  readonly newInvocationId?: () => string;
130
165
  /**
@@ -136,6 +171,10 @@ export interface CreateFrontendSessionRegistryOptions {
136
171
  */
137
172
  readonly newBindToken?: () => string;
138
173
  }
174
+ /** Expected admission refusal; HTTP maps it to a quiet 429 before opening SSE. */
175
+ export declare class FrontendSessionCapacityError extends Error {
176
+ constructor();
177
+ }
139
178
  /**
140
179
  * Creates an in-memory registry of attached surfaces.
141
180
  *
@@ -143,8 +182,8 @@ export interface CreateFrontendSessionRegistryOptions {
143
182
  * so it cannot outlive the process that holds it, and a restart correctly presents as "nothing
144
183
  * attached" rather than as a stale route to a tab that is long gone.
145
184
  *
146
- * @complexity Every operation is O(1) except `detach`, which is O(p + b) in that surface\'s pending
147
- * invocations and bound runs.
185
+ * @complexity `attach` checks O(s × k) live sessions/quota keys when limits are configured;
186
+ * `detach` is O(p + b) in pending invocations and bound runs. Other addressing operations are O(1).
148
187
  */
149
- export declare function createFrontendSessionRegistry(requiredArgs: Record<string, never>, optionalArgs?: Pick<CreateFrontendSessionRegistryOptions, "newInvocationId" | "newBindToken">): FrontendSessionRegistry;
188
+ export declare function createFrontendSessionRegistry(requiredArgs: Record<string, never>, optionalArgs?: Pick<CreateFrontendSessionRegistryOptions, "newInvocationId" | "newBindToken" | "maxSessions" | "maxSessionsPerKey">): FrontendSessionRegistry;
150
189
  //# sourceMappingURL=frontend-session-registry.d.ts.map
@@ -46,6 +46,34 @@
46
46
  * so the existing `timeoutMs` on a tool descriptor covers this for free.
47
47
  */
48
48
  import { randomUUID } from 'node:crypto';
49
+ /** Shared owner comparison for attachment, binding and human-only reply gates. */
50
+ export function sameFrontendOwner({ expected: a, actual: b }, _options = {}) {
51
+ if (!a || !b)
52
+ return a === b;
53
+ return a.workspaceId === b.workspaceId && a.principalId === b.principalId && a.tabId === b.tabId;
54
+ }
55
+ /** Expected admission refusal; HTTP maps it to a quiet 429 before opening SSE. */
56
+ export class FrontendSessionCapacityError extends Error {
57
+ constructor() { super('Frontend session capacity reached'); this.name = 'FrontendSessionCapacityError'; }
58
+ }
59
+ /** Admission is synchronous with attach, so concurrent opens cannot oversubscribe a scope.
60
+ * O(s × k) over the bounded live sessions and the host's quota keys; no retained IP counters.
61
+ * Detach automatically releases capacity, including stale handles and expired connections. */
62
+ function assertSessionCapacity({ sessions, descriptor, options }) {
63
+ if (options.maxSessions !== undefined && sessions.size >= options.maxSessions)
64
+ throw new FrontendSessionCapacityError();
65
+ if (options.maxSessionsPerKey === undefined)
66
+ return;
67
+ for (const key of descriptor.capacityKeys ?? []) {
68
+ let count = 0;
69
+ for (const session of sessions.values()) {
70
+ if (session.descriptor.capacityKeys?.includes(key))
71
+ count++;
72
+ }
73
+ if (count >= options.maxSessionsPerKey)
74
+ throw new FrontendSessionCapacityError();
75
+ }
76
+ }
49
77
  /**
50
78
  * Creates an in-memory registry of attached surfaces.
51
79
  *
@@ -53,8 +81,8 @@ import { randomUUID } from 'node:crypto';
53
81
  * so it cannot outlive the process that holds it, and a restart correctly presents as "nothing
54
82
  * attached" rather than as a stale route to a tab that is long gone.
55
83
  *
56
- * @complexity Every operation is O(1) except `detach`, which is O(p + b) in that surface\'s pending
57
- * invocations and bound runs.
84
+ * @complexity `attach` checks O(s × k) live sessions/quota keys when limits are configured;
85
+ * `detach` is O(p + b) in pending invocations and bound runs. Other addressing operations are O(1).
58
86
  */
59
87
  export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
60
88
  const options = { ...requiredArgs, ...optionalArgs };
@@ -106,11 +134,12 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
106
134
  }
107
135
  return session;
108
136
  }
109
- function attach({ descriptor, deliver }) {
137
+ function attach({ descriptor, deliver, cancel }) {
138
+ assertSessionCapacity({ sessions, descriptor, options });
110
139
  if (sessions.has(descriptor.sessionId)) {
111
140
  throw new Error(`FrontendSessionRegistry: session "${descriptor.sessionId}" is already attached`);
112
141
  }
113
- const session = { descriptor, deliver, pending: new Map() };
142
+ const session = { descriptor, deliver, ...(cancel ? { cancel } : {}), pending: new Map() };
114
143
  sessions.set(descriptor.sessionId, session);
115
144
  const bindToken = newBindToken();
116
145
  bindTokens.set(bindToken, descriptor.sessionId);
@@ -133,6 +162,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
133
162
  }
134
163
  for (const [, entry] of session.pending) {
135
164
  entry.dispose();
165
+ entry.onApprovalStateChange?.({ pending: false });
136
166
  entry.reject(new Error(`frontend session "${descriptor.sessionId}" detached before answering`));
137
167
  }
138
168
  session.pending.clear();
@@ -153,12 +183,12 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
153
183
  runBindings.delete(runId);
154
184
  };
155
185
  }
156
- function bindRunByToken({ runId, bindToken }) {
186
+ function bindRunByToken({ runId, bindToken, owner }) {
157
187
  const sessionId = bindTokens.get(bindToken);
158
188
  // One message for both "no such token" and "the surface holding it detached". Distinguishing
159
189
  // them tells a caller whether a guess was close, which is the feedback a probe needs and the
160
190
  // only feedback it needs.
161
- if (sessionId === undefined) {
191
+ if (sessionId === undefined || !sameFrontendOwner({ expected: sessions.get(sessionId)?.descriptor.owner, actual: owner })) {
162
192
  throw new Error(`FrontendSessionRegistry: cannot bind run "${runId}" — unknown or expired bind token`);
163
193
  }
164
194
  return bindRun({ runId: runId, sessionId: sessionId });
@@ -167,13 +197,18 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
167
197
  // every other failure here. Throwing synchronously out of a Promise-returning function would
168
198
  // make `invoke(...).catch(...)` an uncaught exception for exactly the fail-closed cases this
169
199
  // registry exists to report.
170
- async function invoke({ runId, capabilityId, input }, { signal } = {}) {
200
+ async function invoke({ runId, capabilityId, input }, { signal, onApprovalStateChange } = {}) {
171
201
  const target = resolveTarget(runId, capabilityId);
172
202
  const invocationId = newInvocationId();
173
203
  return new Promise((resolve, reject) => {
174
204
  const cancelled = () => new Error(`"${capabilityId}" was cancelled before the frontend answered`);
175
205
  const onAbort = () => {
176
206
  target.pending.delete(invocationId);
207
+ try {
208
+ target.cancel?.({ invocationId });
209
+ }
210
+ catch { /* A closed channel cannot revive the action. */ }
211
+ onApprovalStateChange?.({ pending: false });
177
212
  reject(cancelled());
178
213
  };
179
214
  const dispose = () => signal?.removeEventListener('abort', onAbort);
@@ -182,7 +217,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
182
217
  return;
183
218
  }
184
219
  signal?.addEventListener('abort', onAbort, { once: true });
185
- target.pending.set(invocationId, { resolve, reject, dispose });
220
+ target.pending.set(invocationId, { resolve, reject, dispose, ...(onApprovalStateChange ? { onApprovalStateChange } : {}) });
186
221
  try {
187
222
  target.deliver({ invocationId, capabilityId, input });
188
223
  }
@@ -190,10 +225,23 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
190
225
  // A dead channel must fail the call rather than leave it pending until the tool times out.
191
226
  target.pending.delete(invocationId);
192
227
  dispose();
228
+ onApprovalStateChange?.({ pending: false });
193
229
  reject(error instanceof Error ? error : new Error(String(error)));
194
230
  }
195
231
  });
196
232
  }
233
+ function progress({ sessionId, invocationId, pending, decision }) {
234
+ const entry = sessions.get(sessionId)?.pending.get(invocationId);
235
+ if (!entry)
236
+ return false;
237
+ // Only the first outcome following a real wait is recorded; replayed frames are observation-free.
238
+ const settledDecision = entry.approvalPending && !pending ? decision : undefined;
239
+ if (settledDecision)
240
+ entry.approvalRecorded = true;
241
+ entry.approvalPending = pending && !entry.approvalRecorded;
242
+ entry.onApprovalStateChange?.({ pending, ...(settledDecision ? { decision: settledDecision } : {}) });
243
+ return true;
244
+ }
197
245
  function settle({ sessionId, invocationId, outcome }) {
198
246
  const session = sessions.get(sessionId);
199
247
  const entry = session?.pending.get(invocationId);
@@ -201,6 +249,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
201
249
  return false;
202
250
  session.pending.delete(invocationId);
203
251
  entry.dispose();
252
+ entry.onApprovalStateChange?.({ pending: false });
204
253
  if (outcome.ok)
205
254
  entry.resolve(outcome.output);
206
255
  else
@@ -213,6 +262,9 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
213
262
  function capabilitiesFor({ runId }) {
214
263
  return sessionFor({ runId: runId })?.capabilities ?? [];
215
264
  }
216
- return { attach, bindRun, bindRunByToken, invoke, settle, capabilitiesFor, sessionFor };
265
+ function ownsSession({ sessionId, owner, bindToken }) {
266
+ return bindTokens.get(bindToken) === sessionId && sameFrontendOwner({ expected: sessions.get(sessionId)?.descriptor.owner, actual: owner });
267
+ }
268
+ return { attach, bindRun, bindRunByToken, invoke, settle, progress, capabilitiesFor, sessionFor, ownsSession };
217
269
  }
218
270
  //# sourceMappingURL=frontend-session-registry.js.map
@@ -35,6 +35,8 @@
35
35
  * their own metadata in it. The engine stays neutral and the host keeps one place to look.
36
36
  */
37
37
  import type { Express } from 'express';
38
+ import type { FrontendSessionOwner } from '../frontend-session-registry.js';
39
+ import type { FrontendSessionsHttpDeps } from './frontend-sessions.js';
38
40
  import type { ToolPolicy, ToolRegistration } from '@jini-ai/core';
39
41
  import { type FrontendCapabilitySpec } from '../index.js';
40
42
  import type { AdapterContext } from '@jini-ai/http-kit';
@@ -67,6 +69,10 @@ export interface CreateFrontendControlOptions {
67
69
  * (see `frontend-capability-tools.ts`'s module doc for why the edge points one way).
68
70
  */
69
71
  readonly capabilities: readonly FrontendCapabilitySpec[];
72
+ readonly session?: Pick<FrontendSessionsHttpDeps, 'routePrefix' | 'resolveOwner' | 'sessionTtlMs' | 'resolveCapacityKeys'> & {
73
+ readonly maxSessions?: number;
74
+ readonly maxSessionsPerKey?: number;
75
+ };
70
76
  /**
71
77
  * Reads this run's bind token out of the host's own run request, or returns `undefined` when the
72
78
  * run has no originating surface.
@@ -98,6 +104,13 @@ export interface CreateFrontendControlOptions {
98
104
  readonly onBindError?: (context: FrontendBindErrorContext) => void;
99
105
  }
100
106
  export interface FrontendControl {
107
+ /** Request-lifetime binding for hosts without daemon runs; release in finally and on cancel. */
108
+ bindRequest(required: {
109
+ runId: string;
110
+ bindToken: string;
111
+ owner: FrontendSessionOwner;
112
+ signal: AbortSignal;
113
+ }, optional?: Record<string, never>): () => void;
101
114
  /**
102
115
  * Mounts the frontend-session stream and response routes. Pass in `createLocalNodeDaemon`'s
103
116
  * `httpExtensions`, or call it directly with `(app, { adapter })` from a host that owns its own
@@ -17,7 +17,10 @@ function defaultBindErrorSink(context) {
17
17
  export function createFrontendControl(requiredArgs, optionalArgs = {}) {
18
18
  const options = { ...requiredArgs, ...optionalArgs };
19
19
  // Closed over, never exposed. See the module doc: reaching this is reaching an ungated `invoke`.
20
- const registry = createFrontendSessionRegistry({});
20
+ const registry = createFrontendSessionRegistry({}, {
21
+ ...(options.session?.maxSessions !== undefined ? { maxSessions: options.session.maxSessions } : {}),
22
+ ...(options.session?.maxSessionsPerKey !== undefined ? { maxSessionsPerKey: options.session.maxSessionsPerKey } : {}),
23
+ });
21
24
  const onBindError = options.onBindError ?? defaultBindErrorSink;
22
25
  const toolRegistrations = createFrontendCapabilityRegistrations({
23
26
  registry,
@@ -28,7 +31,7 @@ export function createFrontendControl(requiredArgs, optionalArgs = {}) {
28
31
  ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
29
32
  });
30
33
  const httpExtension = ({ app, context }) => {
31
- registerFrontendSessionRoutes({ app, deps: { registry }, adapter: context.adapter });
34
+ registerFrontendSessionRoutes({ app, deps: { registry, ...options.session, allowedCapabilities: options.capabilities.map(c => c.id) }, adapter: context.adapter });
32
35
  };
33
36
  /**
34
37
  * The error boundary the `onBindError` contract actually needs. A `RunStartHandler` throwing is
@@ -74,6 +77,14 @@ export function createFrontendControl(requiredArgs, optionalArgs = {}) {
74
77
  .then(release, release)
75
78
  .catch(() => undefined);
76
79
  };
77
- return { httpExtension, toolRegistrations, bindOnStarted };
80
+ const bindRequest = ({ runId, bindToken, owner, signal }) => {
81
+ if (signal.aborted)
82
+ throw new Error('Frontend request cancelled');
83
+ const unbind = registry.bindRunByToken({ runId, bindToken, owner });
84
+ const release = () => { signal.removeEventListener('abort', release); unbind(); };
85
+ signal.addEventListener('abort', release, { once: true });
86
+ return release;
87
+ };
88
+ return { httpExtension, toolRegistrations, bindOnStarted, bindRequest };
78
89
  }
79
90
  //# sourceMappingURL=frontend-control.js.map
@@ -0,0 +1,32 @@
1
+ import type { Request, Response } from 'express';
2
+ import type { FrontendSessionOwner } from '../frontend-session-registry.js';
3
+ /** Per-instance keys isolate visitor control from every other frontend registry.
4
+ * Tokens are bounded and expire; never put either credential in URLs or diagnostics.
5
+ * O(1) bounded cookie/token parsing and signing, no persistent guest database. */
6
+ export declare function createFrontendGuestScope({ workspaceId, cookieName, cookiePath, allowsRequest }: {
7
+ workspaceId: string;
8
+ cookieName: string;
9
+ cookiePath: string;
10
+ allowsRequest: (args: {
11
+ req: Request;
12
+ }) => Promise<boolean>;
13
+ }, { now, lifetimeMs }?: {
14
+ now?: () => number;
15
+ lifetimeMs?: number;
16
+ }): {
17
+ /** Authenticate the cookie independently of a tab for credential-minting budgets. */
18
+ resolveGuest({ req }: {
19
+ req: Request;
20
+ }, _options?: {}): Promise<string | undefined>;
21
+ issue({ req, res }: {
22
+ req: Request;
23
+ res: Response;
24
+ }, _options?: {}): Promise<{
25
+ tabCredential: string;
26
+ } | null>;
27
+ resolveOwner({ req }: {
28
+ req: Request;
29
+ res?: Response;
30
+ }, _options?: {}): Promise<FrontendSessionOwner | null>;
31
+ };
32
+ //# sourceMappingURL=frontend-guest-scope.d.ts.map