@jini-ai/daemon 0.6.15 → 0.6.16
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.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -4
- package/dist/agent-executor/codex/isolation.js +2 -3
- package/dist/agent-executor/contracts.d.ts +1 -1
- package/dist/agent-executor/contracts.js +0 -1
- package/dist/agent-executor/event-translation.d.ts +3 -3
- package/dist/agent-executor/event-translation.js +4 -4
- package/dist/agent-executor/events.js +1 -3
- package/dist/agent-executor/index.d.ts +5 -5
- package/dist/agent-executor/index.js +5 -5
- package/dist/ai-trace-lifecycle.d.ts +1 -1
- package/dist/ai-trace-lifecycle.js +1 -1
- package/dist/core/tool-audit.js +11 -7
- package/dist/frontend-capability-tools.d.ts +2 -0
- package/dist/frontend-capability-tools.js +7 -1
- package/dist/frontend-session-registry.d.ts +42 -3
- package/dist/frontend-session-registry.js +61 -9
- package/dist/http/frontend-control.d.ts +13 -0
- package/dist/http/frontend-control.js +14 -3
- package/dist/http/frontend-guest-scope.d.ts +32 -0
- package/dist/http/frontend-guest-scope.js +63 -0
- package/dist/http/frontend-sessions.d.ts +29 -6
- package/dist/http/frontend-sessions.js +106 -28
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/store/event-log/sqlite-kernel.js +2 -1
- package/dist/terminal-session.d.ts +51 -20
- package/dist/terminal-session.js +6 -89
- package/dist/terminal.adapter.d.ts +22 -0
- package/dist/terminal.adapter.js +47 -0
- package/dist/tool-executor.d.ts +4 -2
- package/dist/tool-executor.js +50 -14
- package/dist/tool-result-media.d.ts +4 -0
- package/dist/tool-result-media.js +3 -2
- package/package.json +29 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.6.16
|
|
2
|
+
|
|
3
|
+
### Minor Changes
|
|
4
|
+
|
|
5
|
+
- Remove the GitHub Copilot CLI agent: its def, `createCopilotStreamHandler`/`CopilotStreamEvent`,
|
|
6
|
+
account model discovery and the `@github/copilot-sdk` optional peer, the MCP installer's `copilot`
|
|
7
|
+
slug, and its icon. A saved `copilot` agent id no longer resolves (`getAgentDef` returns `null`).
|
|
8
|
+
CopilotKit/AG-UI is unrelated and unchanged.
|
|
9
|
+
|
|
10
|
+
Drop peers no runtime entry loads: `express` from core and http-kit (type-only imports; type
|
|
11
|
+
consumers keep `@types/express`), `vue` from ui-kit, `react-dom` from vibecoding.
|
|
12
|
+
|
|
1
13
|
## 0.6.13
|
|
2
14
|
|
|
3
15
|
### 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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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", "
|
|
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'>;
|
|
@@ -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.
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
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
|
|
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.
|
|
168
|
-
*
|
|
169
|
-
* `
|
|
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,
|
|
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
|
|
15
|
-
* `streamFormat` families. The JSON-stream-parser family — the
|
|
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`, `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
15
|
-
* `streamFormat` families. The JSON-stream-parser family — the
|
|
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`, `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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,
|
|
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,
|
|
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()`.
|
package/dist/core/tool-audit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|