@jini-ai/daemon 0.6.15-rc.2 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -4
  3. package/dist/agent-executor/codex/home-config.js +3 -2
  4. package/dist/agent-executor/codex/home-staging.js +2 -2
  5. package/dist/agent-executor/codex/isolation.js +4 -3
  6. package/dist/agent-executor/contracts.d.ts +1 -1
  7. package/dist/agent-executor/contracts.js +0 -1
  8. package/dist/agent-executor/event-translation.d.ts +3 -3
  9. package/dist/agent-executor/event-translation.js +6 -6
  10. package/dist/agent-executor/events.js +65 -51
  11. package/dist/agent-executor/index.d.ts +5 -5
  12. package/dist/agent-executor/index.js +5 -5
  13. package/dist/agent-executor/launch.js +30 -46
  14. package/dist/agent-executor/live-session-state.js +2 -2
  15. package/dist/agent-executor/mcp-bridge.js +5 -0
  16. package/dist/agent-executor/message-relay.js +5 -6
  17. package/dist/agent-executor/run-output.js +7 -2
  18. package/dist/ai-trace-lifecycle.d.ts +1 -1
  19. package/dist/ai-trace-lifecycle.js +1 -1
  20. package/dist/close-status.js +6 -2
  21. package/dist/core/omit-keys.d.ts +7 -0
  22. package/dist/core/omit-keys.js +12 -0
  23. package/dist/core/read-only-tools.js +5 -1
  24. package/dist/core/tool-audit.js +11 -7
  25. package/dist/core/tool-gaps.js +1 -1
  26. package/dist/delegated-result-correlation.js +2 -2
  27. package/dist/frontend-capability-tools.d.ts +2 -0
  28. package/dist/frontend-capability-tools.js +7 -1
  29. package/dist/frontend-session-registry.d.ts +42 -3
  30. package/dist/frontend-session-registry.js +61 -9
  31. package/dist/http/agents.d.ts +4 -0
  32. package/dist/http/attachments.js +13 -12
  33. package/dist/http/component-catalog.js +1 -1
  34. package/dist/http/connectors.js +7 -5
  35. package/dist/http/db-ops.js +5 -0
  36. package/dist/http/delegated-tools.js +5 -0
  37. package/dist/http/frontend-control.d.ts +13 -0
  38. package/dist/http/frontend-control.js +14 -3
  39. package/dist/http/frontend-guest-scope.d.ts +32 -0
  40. package/dist/http/frontend-guest-scope.js +63 -0
  41. package/dist/http/frontend-sessions.d.ts +29 -6
  42. package/dist/http/frontend-sessions.js +108 -28
  43. package/dist/http/host-tools.js +7 -5
  44. package/dist/http/index.d.ts +1 -0
  45. package/dist/http/index.js +1 -0
  46. package/dist/http/memory.js +3 -1
  47. package/dist/http/model-proxy.js +13 -8
  48. package/dist/http/research-tavily.adapter.js +2 -1
  49. package/dist/http/routines.js +7 -5
  50. package/dist/http/terminals.js +5 -0
  51. package/dist/http/tool-catalog.js +1 -1
  52. package/dist/http/xai.js +10 -7
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +2 -2
  55. package/dist/legacy-data-migration.d.ts +0 -12
  56. package/dist/legacy-data-migration.js +21 -13
  57. package/dist/remote-tool-executor.js +2 -1
  58. package/dist/routines/schedule.js +2 -1
  59. package/dist/routines/scheduler.js +6 -7
  60. package/dist/run-lifecycle.js +2 -1
  61. package/dist/store/agent-sessions/sql.js +7 -3
  62. package/dist/store/event-log/sqlite-kernel.js +7 -2
  63. package/dist/store/event-log/sqlite.js +6 -2
  64. package/dist/surface-exchanges.js +3 -3
  65. package/dist/terminal-session.d.ts +51 -20
  66. package/dist/terminal-session.js +10 -94
  67. package/dist/terminal.adapter.d.ts +22 -0
  68. package/dist/terminal.adapter.js +47 -0
  69. package/dist/tool-executor.d.ts +4 -2
  70. package/dist/tool-executor.js +95 -15
  71. package/dist/tool-recovery/recovery.js +5 -3
  72. package/dist/tool-recovery/redaction.js +1 -2
  73. package/dist/tool-recovery/stack.js +5 -1
  74. package/dist/tool-result-media.d.ts +4 -0
  75. package/dist/tool-result-media.js +10 -3
  76. package/dist/tool-result-surfaces.js +10 -9
  77. package/package.json +30 -34
@@ -13,6 +13,12 @@ export function validateSessionKernel({ kernel, dialect, transports }) {
13
13
  function runSessionQuery(kernel, body) {
14
14
  return kernel.run(db => body(db));
15
15
  }
16
+ /** The one atomic upsert behind `setSessionId`: insert the pair, or replace its session id and time. */
17
+ function upsertSessionQuery({ conversationId, agentId, sessionId, updatedAt }) {
18
+ return (db) => db.insertInto('assistant_agent_sessions')
19
+ .values({ conversation_id: conversationId, agent_id: agentId, session_id: sessionId, updated_at: updatedAt })
20
+ .onConflict(oc => oc.columns(['conversation_id', 'agent_id']).doUpdateSet({ session_id: sessionId, updated_at: updatedAt })).execute();
21
+ }
16
22
  /** Translate opaque driver failures without losing their cause or mutating host state. */
17
23
  async function atSessionBoundary(operation) {
18
24
  try {
@@ -43,9 +49,7 @@ export function createSqlAgentSessionStore({ kernel }, { clock = createSystemClo
43
49
  setSessionId({ conversationId, agentId, sessionId }) {
44
50
  return atSessionBoundary(async () => {
45
51
  const updatedAt = clock.nowMs();
46
- await runSessionQuery(kernel, db => db.insertInto('assistant_agent_sessions')
47
- .values({ conversation_id: conversationId, agent_id: agentId, session_id: sessionId, updated_at: updatedAt })
48
- .onConflict(oc => oc.columns(['conversation_id', 'agent_id']).doUpdateSet({ session_id: sessionId, updated_at: updatedAt })).execute());
52
+ await runSessionQuery(kernel, upsertSessionQuery({ conversationId, agentId, sessionId, updatedAt }));
49
53
  });
50
54
  },
51
55
  clearSessionId({ conversationId, agentId }) {
@@ -1,4 +1,5 @@
1
1
  /** Async SQLite execution over the host kernel; event-log policy lives in sqlite-policy. */
2
+ import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
2
3
  import { createSystemClock } from '@jini-ai/core/primitives';
3
4
  import { SQLITE_EVENT_LOG_SCHEMA, resolveMaxEntriesPerRun, assertValidReplayCursor, existingAppendResult, replayResult, rowToEntry, } from './sqlite-policy.js';
4
5
  /**
@@ -57,7 +58,7 @@ export async function createKernelSqliteEventLog(required, optional = {}) {
57
58
  async function setupSchema({ db }, options = {}) {
58
59
  if (options.schemaSetup !== 'host') {
59
60
  // Keep the existing synchronous factory free of a new eager peer import.
60
- const { sql } = await import('kysely');
61
+ const { sql } = await loadOptionalPeer({ feature: 'SQL storage adapters', dependency: 'kysely', load: () => import('kysely') });
61
62
  // Kysely executes one statement at a time; the owner DDL contains no embedded semicolons.
62
63
  for (const statement of SQLITE_EVENT_LOG_SCHEMA.split(';').filter(part => part.trim())) {
63
64
  await sql.raw(statement).execute(db);
@@ -129,7 +130,11 @@ async function replayInKernel({ db, runId, afterCursor }) {
129
130
  const result = replayResult({ run: { run_id: runId, next_cursor: first.next_cursor }, oldest, afterCursor });
130
131
  if (result.kind !== 'ok')
131
132
  return result;
132
- const entries = rows.flatMap(({ cursor, event, data, recorded_at }) => cursor === null || event === null || data === null || recorded_at === null ? [] : [rowToEntry({ cursor, event, data, recorded_at })]);
133
+ // A row missing any event column carries no entry.
134
+ const entries = rows.flatMap(({ cursor, event, data, recorded_at }) => {
135
+ const missing = cursor === null || event === null;
136
+ return missing || data === null || recorded_at === null ? [] : [rowToEntry({ cursor, event, data, recorded_at })];
137
+ });
133
138
  return { ...result, entries };
134
139
  }
135
140
  //# sourceMappingURL=sqlite-kernel.js.map
@@ -58,8 +58,12 @@ export function openSqliteEventLog(input, options = {}) {
58
58
  } }, { pragmas: ['journal_mode = WAL'] });
59
59
  const log = bindSqliteEventLog(db, options);
60
60
  let closed = false;
61
- return { ...log, async close(_args) { if (closed)
62
- return; closed = true; db.close(); } };
61
+ return { ...log, async close(_args) {
62
+ if (closed)
63
+ return;
64
+ closed = true;
65
+ db.close();
66
+ } };
63
67
  }
64
68
  catch (error) {
65
69
  try {
@@ -143,9 +143,9 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
143
143
  // Unknown and closed are the same situation to a human whose dialog is no longer waiting.
144
144
  if (!entry)
145
145
  return { ok: false, reason: 'unknown-or-closed' };
146
- if ((spec.toolId !== undefined && entry.binding.toolId !== spec.toolId)
147
- || (entry.binding.channel ?? deps.defaultChannel) !== (spec.channel ?? deps.defaultChannel)
148
- || entry.binding.principalId !== spec.principalId) {
146
+ const toolMismatch = spec.toolId !== undefined && entry.binding.toolId !== spec.toolId;
147
+ const channelMismatch = (entry.binding.channel ?? deps.defaultChannel) !== (spec.channel ?? deps.defaultChannel);
148
+ if (toolMismatch || channelMismatch || entry.binding.principalId !== spec.principalId) {
149
149
  // A wrong-binding delivery must not consume a conversation the right human is still having.
150
150
  return { ok: false, reason: 'binding-mismatch' };
151
151
  }
@@ -1,3 +1,50 @@
1
+ /**
2
+ * @module terminal-session
3
+ *
4
+ * Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
5
+ * OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
6
+ * (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
7
+ * for the full design discussion this module implements a specific decision
8
+ * from). The generic ring-buffer/coalescing/SSE-agnostic session engine
9
+ * already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
10
+ * before this task) — this module does **not** reimplement that. It adds
11
+ * exactly the three things that engine deliberately left to a caller:
12
+ *
13
+ * 1. **A host-injected `PtySpawn` or `TerminalService`.** The native adapter
14
+ * lives exclusively at `@jini-ai/daemon/terminal`; this default-entry
15
+ * session manager never imports it. The host opts in by supplying
16
+ * `loadSpawnPty: () => loadRealSpawnPty({})` from that subpath.
17
+ *
18
+ * 2. **Session-token gating.** The proposal's blocker was that a
19
+ * *session-scoped* dangerous capability (once a shell is spawned, every
20
+ * subsequent `stdin` write is arbitrary unaudited input for the
21
+ * session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
22
+ * The resolved design: `ToolExecutor.execute('terminal.create', ...)`
23
+ * (via {@link createTerminalToolRegistrations}) is the one full
24
+ * deny-by-default gated call — it authorizes *creating* a session.
25
+ * Every subsequent `write`/`resize`/`kill`/`attach` call against that
26
+ * session id is validated by a lighter, still-explicit check: this
27
+ * module records which `Principal` created each session at `create()`
28
+ * time, and every later call verifies the calling principal matches,
29
+ * denying (as a `'not-found'` result, never a distinguishable "forbidden"
30
+ * — see {@link checkOwnership}'s doc for why) otherwise. This is not
31
+ * "the session id is unguessable therefore secure" — it is a real,
32
+ * per-call identity check, independent of how hard the id is to guess.
33
+ *
34
+ * 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
35
+ * faithful port of it) has no lock between a `kill` and a concurrent
36
+ * `write`/`resize` on the same session id: `kill()` only *requests* the
37
+ * real OS process die (sends `SIGTERM`) — the session's `status` does not
38
+ * flip to `'exited'` until the pty's own `onExit` callback fires,
39
+ * asynchronously, once the real process actually dies. A `write`/`resize`
40
+ * processed in that window still observes `status: 'running'` and still
41
+ * reaches the real pty. This module closes that window with its own
42
+ * immediate, lock-protected `killed` flag (see {@link runExclusive} and
43
+ * the `write`/`resize`/`kill` implementations below): once a `kill` call
44
+ * has run its own critical section, any `write`/`resize` queued behind it
45
+ * for the same session id sees `killed: true` and is rejected — it never
46
+ * depends on the real process having actually exited yet.
47
+ */
1
48
  import type { Principal, ToolPolicy, ToolRegistration } from '@jini-ai/core';
2
49
  import { type PtySpawn, type TerminalService, type TerminalSseSink } from '@jini-ai/platform';
3
50
  export type { TerminalSseSink } from '@jini-ai/platform';
@@ -13,24 +60,6 @@ export declare const TERMINAL_CREATE_TOOL_ID = "terminal.create";
13
60
  * merely by registering the tool.
14
61
  */
15
62
  export declare const denyAllTerminalCreatePolicy: ToolPolicy;
16
- /**
17
- * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
18
- * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
19
- * static top-level import) so a host process whose platform/environment
20
- * lacks a usable `node-pty` addon can still boot — only an actual
21
- * `terminal.create` call fails, cleanly, rather than crashing at
22
- * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
23
- * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
24
- * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
25
- * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
26
- * but with the resolver anchored at **this** module
27
- * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
28
- * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
29
- * its default `require.resolve('node-pty')` would not find the copy this
30
- * package (`@jini-ai/daemon`) actually declares — this is exactly the
31
- * resolver injection seam `spawnHelperCandidatePaths` was built for.
32
- */
33
- export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>): Promise<PtySpawn>;
34
63
  /** The public, principal-neutral session snapshot this module exposes — deliberately its own shape rather than re-exporting `@jini-ai/platform`'s `TerminalSession` verbatim, since that type carries an OD-shaped per-session grouping key (see `@jini-ai/platform`'s `terminal.ts`) this module replaces entirely with the caller-supplied `resourceRef` (see {@link CreateTerminalSessionOptions}), tracked only in this module's own metadata and never forwarded to `@jini-ai/platform` at all — this package's own identifier-neutrality lint (`__tests__/identifier-lint.test.ts`) forbids that origin field's exact name from appearing anywhere in this package's source. */
35
64
  export interface TerminalSessionInfo {
36
65
  readonly id: string;
@@ -129,9 +158,9 @@ export interface TerminalSessionManager {
129
158
  }): Promise<void>;
130
159
  }
131
160
  export interface CreateTerminalSessionManagerOptions {
132
- /** Injected for tests (a fake session manager without a real pty) or an alternate host wiring. @default a fresh `@jini-ai/platform` `TerminalService` backed by {@link loadRealSpawnPty}. */
161
+ /** Host-owned session service. Otherwise a fresh platform service uses the injected `loadSpawnPty`. */
133
162
  readonly terminalService?: TerminalService;
134
- /** @default {@link loadRealSpawnPty} */
163
+ /** Host-owned PTY loader, e.g. `() => loadRealSpawnPty({})` from `@jini-ai/daemon/terminal`. Required for creation unless `terminalService` is supplied. */
135
164
  readonly loadSpawnPty?: () => Promise<PtySpawn>;
136
165
  readonly maxEvents?: number;
137
166
  readonly maxBufferBytes?: number;
@@ -146,6 +175,8 @@ export interface CreateTerminalSessionManagerOptions {
146
175
  * around a `@jini-ai/platform` `TerminalService` adding session ownership,
147
176
  * the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
148
177
  * `ToolExecutor` gating for creation. See module doc for the full design.
178
+ * Without a host-owned service or loader, boot is inert and `create()` rejects
179
+ * with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
149
180
  */
150
181
  export declare function createTerminalSessionManager(_requiredArgs: Record<string, never>, options?: Pick<CreateTerminalSessionManagerOptions, "terminalService" | "loadSpawnPty" | "maxEvents" | "maxBufferBytes" | "exitTailBytes" | "flushIntervalMs" | "flushThresholdBytes" | "ttlMs" | "shutdownGraceMs">): TerminalSessionManager;
151
182
  export interface CreateTerminalToolRegistrationsOptions {
@@ -1,54 +1,4 @@
1
- /**
2
- * @module terminal-session
3
- *
4
- * Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
5
- * OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
6
- * (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
7
- * for the full design discussion this module implements a specific decision
8
- * from). The generic ring-buffer/coalescing/SSE-agnostic session engine
9
- * already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
10
- * before this task) — this module does **not** reimplement that. It adds
11
- * exactly the three things that engine deliberately left to a caller:
12
- *
13
- * 1. **A real `node-pty`-backed `PtySpawn`.** `@jini-ai/platform` intentionally
14
- * does not depend on `node-pty` (a native compiled addon); `@jini-ai/daemon`
15
- * does (see this package's `package.json` and `archived provenance ledger`'s dated
16
- * section for why that dependency now lives here, not in `@jini-ai/http-kit` or
17
- * `@jini-ai/platform`). {@link loadRealSpawnPty} is the real, dynamically
18
- * imported default the reference session manager below uses.
19
- *
20
- * 2. **Session-token gating.** The proposal's blocker was that a
21
- * *session-scoped* dangerous capability (once a shell is spawned, every
22
- * subsequent `stdin` write is arbitrary unaudited input for the
23
- * session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
24
- * The resolved design: `ToolExecutor.execute('terminal.create', ...)`
25
- * (via {@link createTerminalToolRegistrations}) is the one full
26
- * deny-by-default gated call — it authorizes *creating* a session.
27
- * Every subsequent `write`/`resize`/`kill`/`attach` call against that
28
- * session id is validated by a lighter, still-explicit check: this
29
- * module records which `Principal` created each session at `create()`
30
- * time, and every later call verifies the calling principal matches,
31
- * denying (as a `'not-found'` result, never a distinguishable "forbidden"
32
- * — see {@link checkOwnership}'s doc for why) otherwise. This is not
33
- * "the session id is unguessable therefore secure" — it is a real,
34
- * per-call identity check, independent of how hard the id is to guess.
35
- *
36
- * 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
37
- * faithful port of it) has no lock between a `kill` and a concurrent
38
- * `write`/`resize` on the same session id: `kill()` only *requests* the
39
- * real OS process die (sends `SIGTERM`) — the session's `status` does not
40
- * flip to `'exited'` until the pty's own `onExit` callback fires,
41
- * asynchronously, once the real process actually dies. A `write`/`resize`
42
- * processed in that window still observes `status: 'running'` and still
43
- * reaches the real pty. This module closes that window with its own
44
- * immediate, lock-protected `killed` flag (see {@link runExclusive} and
45
- * the `write`/`resize`/`kill` implementations below): once a `kill` call
46
- * has run its own critical section, any `write`/`resize` queued behind it
47
- * for the same session id sees `killed: true` and is rejected — it never
48
- * depends on the real process having actually exited yet.
49
- */
50
- import { createRequire } from 'node:module';
51
- import { createTerminalService, ensureSpawnHelperExecutable, spawnHelperCandidatePaths, } from '@jini-ai/platform';
1
+ import { createTerminalService, } from '@jini-ai/platform';
52
2
  /** The `ToolRegistry` id `terminal.create` executes through — see module doc §2. */
53
3
  export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
54
4
  /**
@@ -63,43 +13,6 @@ export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
63
13
  export const denyAllTerminalCreatePolicy = {
64
14
  authorize: () => 'deny',
65
15
  };
66
- const daemonRequire = createRequire(import.meta.url);
67
- /** Adapts a real `node-pty` `IPty` to `@jini-ai/platform`'s narrower `PtyProcess` port — explicit per-member mapping rather than relying on structural assignability, matching this package's established "explicit adapter, no implicit coercion" convention (e.g. `agent-executor.ts`'s `toStringEnvRecord`). */
68
- function toPtyProcess(pty) {
69
- return {
70
- onData: (cb) => {
71
- pty.onData(cb);
72
- },
73
- onExit: (cb) => {
74
- pty.onExit(cb);
75
- },
76
- write: (input) => pty.write(input),
77
- resize: (cols, rows) => pty.resize(cols, rows),
78
- kill: (signal) => pty.kill(signal),
79
- };
80
- }
81
- /**
82
- * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
83
- * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
84
- * static top-level import) so a host process whose platform/environment
85
- * lacks a usable `node-pty` addon can still boot — only an actual
86
- * `terminal.create` call fails, cleanly, rather than crashing at
87
- * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
88
- * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
89
- * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
90
- * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
91
- * but with the resolver anchored at **this** module
92
- * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
93
- * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
94
- * its default `require.resolve('node-pty')` would not find the copy this
95
- * package (`@jini-ai/daemon`) actually declares — this is exactly the
96
- * resolver injection seam `spawnHelperCandidatePaths` was built for.
97
- */
98
- export async function loadRealSpawnPty(_requiredArgs) {
99
- ensureSpawnHelperExecutable(spawnHelperCandidatePaths({ resolve: (specifier) => daemonRequire.resolve(specifier) }));
100
- const nodePty = await import('node-pty');
101
- return (shell, args, options) => toPtyProcess(nodePty.spawn(shell, args, options));
102
- }
103
16
  function toSessionInfo(session, resourceRef) {
104
17
  return {
105
18
  id: session.id,
@@ -144,11 +57,15 @@ async function runExclusive(locks, id, fn) {
144
57
  * around a `@jini-ai/platform` `TerminalService` adding session ownership,
145
58
  * the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
146
59
  * `ToolExecutor` gating for creation. See module doc for the full design.
60
+ * Without a host-owned service or loader, boot is inert and `create()` rejects
61
+ * with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
147
62
  */
148
63
  export function createTerminalSessionManager(_requiredArgs, options = {}) {
149
64
  const terminalService = options.terminalService ??
150
65
  createTerminalService({
151
- loadSpawnPty: options.loadSpawnPty ?? (() => loadRealSpawnPty({})),
66
+ loadSpawnPty: options.loadSpawnPty ?? (() => {
67
+ throw new Error('Interactive terminals require a host-supplied terminalService or loadSpawnPty. Import loadRealSpawnPty from @jini-ai/daemon/terminal to enable node-pty.');
68
+ }),
152
69
  ...definedOnly({
153
70
  maxEvents: options.maxEvents,
154
71
  maxBufferBytes: options.maxBufferBytes,
@@ -200,11 +117,10 @@ export function createTerminalSessionManager(_requiredArgs, options = {}) {
200
117
  const result = [];
201
118
  for (const session of terminalService.list()) {
202
119
  const meta = metadata.get(session.id);
203
- if (!meta || meta.principalId !== principal.id)
204
- continue;
205
- if (filter.resourceRef !== undefined && meta.resourceRef !== filter.resourceRef)
206
- continue;
207
- result.push(toSessionInfo(session, meta.resourceRef));
120
+ const visible = meta !== undefined && meta.principalId === principal.id
121
+ && (filter.resourceRef === undefined || meta.resourceRef === filter.resourceRef);
122
+ if (visible)
123
+ result.push(toSessionInfo(session, meta.resourceRef));
208
124
  }
209
125
  return result;
210
126
  }
@@ -0,0 +1,22 @@
1
+ import { type PtySpawn } from '@jini-ai/platform';
2
+ /**
3
+ * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
4
+ * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
5
+ * static top-level import) so a host process whose platform/environment
6
+ * lacks a usable `node-pty` addon can still boot — only an actual
7
+ * `terminal.create` call fails, cleanly, rather than crashing at
8
+ * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
9
+ * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
10
+ * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
11
+ * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
12
+ * but with the resolver anchored at **this** module
13
+ * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
14
+ * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
15
+ * its default `require.resolve('node-pty')` would not find the copy this
16
+ * package (`@jini-ai/daemon`) actually declares — this is exactly the
17
+ * resolver injection seam `spawnHelperCandidatePaths` was built for.
18
+ * @throws A feature-specific load error with the original resolution/ABI error as `cause`.
19
+ * @complexity O(1) adapter work; module loading and helper repair belong to the platform.
20
+ */
21
+ export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>, _optionalArgs?: Record<string, never>): Promise<PtySpawn>;
22
+ //# sourceMappingURL=terminal.adapter.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Opt-in native PTY adapter. Import from `@jini-ai/daemon/terminal` and inject
3
+ * `() => loadRealSpawnPty({})` into the session manager's `loadSpawnPty` port.
4
+ */
5
+ import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
6
+ import { createRequire } from 'node:module';
7
+ import { ensureSpawnHelperExecutable, spawnHelperCandidatePaths, } from '@jini-ai/platform';
8
+ const daemonRequire = createRequire(import.meta.url);
9
+ /** Adapts a real `node-pty` `IPty` to `@jini-ai/platform`'s narrower `PtyProcess` port — explicit per-member mapping rather than relying on structural assignability, matching this package's established "explicit adapter, no implicit coercion" convention (e.g. `agent-executor.ts`'s `toStringEnvRecord`). */
10
+ function toPtyProcess(pty) {
11
+ return {
12
+ onData: (cb) => {
13
+ pty.onData(cb);
14
+ },
15
+ onExit: (cb) => {
16
+ pty.onExit(cb);
17
+ },
18
+ write: (input) => pty.write(input),
19
+ resize: (cols, rows) => pty.resize(cols, rows),
20
+ kill: (signal) => pty.kill(signal),
21
+ };
22
+ }
23
+ /**
24
+ * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
25
+ * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
26
+ * static top-level import) so a host process whose platform/environment
27
+ * lacks a usable `node-pty` addon can still boot — only an actual
28
+ * `terminal.create` call fails, cleanly, rather than crashing at
29
+ * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
30
+ * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
31
+ * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
32
+ * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
33
+ * but with the resolver anchored at **this** module
34
+ * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
35
+ * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
36
+ * its default `require.resolve('node-pty')` would not find the copy this
37
+ * package (`@jini-ai/daemon`) actually declares — this is exactly the
38
+ * resolver injection seam `spawnHelperCandidatePaths` was built for.
39
+ * @throws A feature-specific load error with the original resolution/ABI error as `cause`.
40
+ * @complexity O(1) adapter work; module loading and helper repair belong to the platform.
41
+ */
42
+ export async function loadRealSpawnPty(_requiredArgs, _optionalArgs = {}) {
43
+ ensureSpawnHelperExecutable(spawnHelperCandidatePaths({ resolve: (specifier) => daemonRequire.resolve(specifier) }));
44
+ const nodePty = await loadOptionalPeer({ feature: 'Interactive terminals', dependency: 'node-pty', load: () => import('node-pty') });
45
+ return (shell, args, options) => toPtyProcess(nodePty.spawn(shell, args, options));
46
+ }
47
+ //# sourceMappingURL=terminal.adapter.js.map
@@ -17,7 +17,7 @@ export interface ToolConfirmationRequest {
17
17
  * The transport-supplied seam for the two touchpoints a headless kernel
18
18
  * cannot render itself. Both are optional: a tool whose `ToolPolicy`
19
19
  * always resolves definitively needs no `onAuthorize`, and a tool with
20
- * `requiresConfirmation` unset never triggers `onConfirm`.
20
+ * `requiresConfirmation` unset has no default prompt; a host may strengthen a single call with `requireConfirmation: true`.
21
21
  */
22
22
  export interface ExecutionDelegate {
23
23
  /**
@@ -42,7 +42,7 @@ export interface ExecutionDelegate {
42
42
  */
43
43
  onConfirm?(request: ToolConfirmationRequest): ConfirmationDecision | Promise<ConfirmationDecision> | void;
44
44
  }
45
- export type ToolExecutionPhase = 'requested' | 'authorized' | 'denied' | 'confirmed' | 'confirmation-denied' | 'started' | 'completed' | 'failed' | 'timed-out' | 'cancelled';
45
+ export type ToolExecutionPhase = 'requested' | 'authorized' | 'denied' | 'confirmed' | 'approval-recorded' | 'confirmation-denied' | 'started' | 'completed' | 'failed' | 'timed-out' | 'cancelled';
46
46
  export interface ToolExecutionAuditEvent {
47
47
  readonly phase: ToolExecutionPhase;
48
48
  readonly at: number;
@@ -85,6 +85,7 @@ export interface ToolExecutor {
85
85
  * @param emitSurface - Transport-supplied seam letting the handler push a human-only surface into
86
86
  * the run's event stream *before* this call settles. Omit for a caller with no run event stream;
87
87
  * see `@jini-ai/core`'s `SurfaceEmitter` for why a handler that waits on a human needs it.
88
+ * @param requireConfirmation - Strengthens this call only; cannot bypass a descriptor confirmation.
88
89
  * @throws {ToolInputError} If `toolId` isn't registered, with up to eight registered prefix matches.
89
90
  */
90
91
  execute(args: {
@@ -95,6 +96,7 @@ export interface ToolExecutor {
95
96
  }, optionalArgs?: {
96
97
  readonly signal?: AbortSignal;
97
98
  readonly emitSurface?: SurfaceEmitter;
99
+ readonly requireConfirmation?: true;
98
100
  }): Promise<ToolExecutionResult>;
99
101
  /** @throws If `executionId` has no confirmation currently pending (already resumed, never required one, or unknown). */
100
102
  resumeConfirmation(args: {
@@ -46,7 +46,7 @@
46
46
  * answer gets back in.
47
47
  */
48
48
  import { randomUUID } from 'node:crypto';
49
- import { ToolInputError, withNormalizationNotes, ToolInputNormalizationError, } from '@jini-ai/core';
49
+ import { ToolInputError, APPROVAL_WAIT_TIMEOUT_MS, withNormalizationNotes, ToolInputNormalizationError, isReadOnlyTool, } from '@jini-ai/core';
50
50
  import { authorizeToolInvocation } from '@jini-ai/core/composition';
51
51
  import { toolCandidates } from './core/tool-candidates.js';
52
52
  /**
@@ -104,6 +104,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
104
104
  // Finished executionIds, oldest first (Set insertion order): the only records `retireAudit` evicts.
105
105
  const finishedAudits = new Set();
106
106
  const pendingConfirmations = new Map();
107
+ // Owned by the executor, not a bridge: concurrent HTTP/MCP calls create separate bridges.
108
+ const approvalWaits = new Map();
107
109
  const activeControllers = new Map();
108
110
  /**
109
111
  * Marks a pending confirmation `cancel()` just resolved (as opposed to a genuine human `deny`),
@@ -114,6 +116,34 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
114
116
  * `requestConfirmation()` settles, so it can never leak onto a later execution.
115
117
  */
116
118
  const cancelledConfirmations = new Set();
119
+ /** Records only actual human waits; terminal decisions and aborts release the run's write block.
120
+ * @complexity O(1) time/space per pending execution; empty run entries are removed. */
121
+ function approvalStateChange({ run, executionId, toolId, pending }, _optional = {}) {
122
+ if (pending) {
123
+ const waits = approvalWaits.get(run.id) ?? new Map();
124
+ waits.set(executionId, toolId);
125
+ approvalWaits.set(run.id, waits);
126
+ return;
127
+ }
128
+ const waits = approvalWaits.get(run.id);
129
+ waits?.delete(executionId);
130
+ if (waits?.size === 0)
131
+ approvalWaits.delete(run.id);
132
+ }
133
+ /** Refuses writes using the registered read-only contract, never a model's claim.
134
+ * The validation result reaches every delegated transport as an actionable error, without
135
+ * resolving or approving the original call. @complexity O(1) time/space. */
136
+ function approvalRefusal({ executionId, run, descriptor }, _optional = {}) {
137
+ const pendingToolId = approvalWaits.get(run.id)?.values().next().value;
138
+ if (pendingToolId === undefined || isReadOnlyTool({ descriptor }))
139
+ return undefined;
140
+ const error = JSON.stringify({
141
+ status: 'awaiting_approval', started: false, toolId: descriptor.id, pendingToolId,
142
+ message: 'Another action is waiting for human confirmation and has not started. This write was refused. Reads may continue. Never say an action has started until its tool result explicitly says it started.',
143
+ });
144
+ appendEvent(executionId, 'failed', error);
145
+ return { executionId, status: 'failed', errorKind: 'validation', error };
146
+ }
117
147
  function appendEvent(executionId, phase, detail) {
118
148
  // Every call site passes an `executionId` this same `execute()` call
119
149
  // already registered in `audits` (right before its first `appendEvent`
@@ -176,15 +206,45 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
176
206
  * what distinguishes a `timed-out` result from a `cancelled` one.
177
207
  */
178
208
  function startTimeout(controller, timeoutMs) {
179
- if (timeoutMs === undefined)
180
- return { handle: undefined, timedOut: () => false };
209
+ let handle;
181
210
  let timedOut = false;
182
- const handle = setTimeout(() => {
183
- timedOut = true;
184
- controller.abort();
185
- }, timeoutMs);
186
- handle.unref?.();
187
- return { handle, timedOut: () => timedOut };
211
+ let waiting = false;
212
+ let approvalWindowUsed = false;
213
+ let remaining = timeoutMs;
214
+ let started = Date.now();
215
+ const stop = () => {
216
+ if (handle === undefined)
217
+ return;
218
+ clearTimeout(handle);
219
+ handle = undefined;
220
+ };
221
+ const arm = (duration) => {
222
+ stop();
223
+ started = Date.now();
224
+ if (duration === undefined)
225
+ return;
226
+ handle = setTimeout(() => { timedOut = true; controller.abort(); }, duration);
227
+ handle.unref?.();
228
+ };
229
+ arm(remaining);
230
+ return {
231
+ stop,
232
+ timedOut: () => timedOut,
233
+ /** Human waiting is bounded independently and cannot replenish the execution budget.
234
+ * Duplicate progress frames cannot extend either deadline. */
235
+ approval: ({ pending }) => {
236
+ if (pending === waiting || controller.signal.aborted)
237
+ return;
238
+ if (pending && approvalWindowUsed)
239
+ return;
240
+ if (pending)
241
+ approvalWindowUsed = true;
242
+ if (pending && remaining !== undefined)
243
+ remaining = Math.max(0, remaining - (Date.now() - started));
244
+ waiting = pending;
245
+ arm(pending ? APPROVAL_WAIT_TIMEOUT_MS : remaining);
246
+ },
247
+ };
188
248
  }
189
249
  /** Records a terminal transition that carries no detail and returns its bare result. */
190
250
  function terminal({ executionId, status }) {
@@ -242,7 +302,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
242
302
  * Acts on the authorization outcome synchronously: `{ refusal }` if the call ends here
243
303
  * (cancelled meanwhile, or denied), otherwise records `authorized` and returns `{ allowed }`.
244
304
  */
245
- function admitAuthorized({ executionId, resolved, signal }) {
305
+ function admitAuthorized({ executionId, resolved, signal, run }) {
246
306
  // `authorize()` isn't itself signal-aware — it's typically a fast policy check, not a
247
307
  // human-in-the-loop wait — so this is a post-await check rather than a mid-flight abort, the
248
308
  // same idiom the post-handler check below already uses: a signal that fired while `authorize()`
@@ -253,6 +313,10 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
253
313
  return { refusal: terminal({ executionId, status: 'denied' }) };
254
314
  const { normalizationNotes } = resolved;
255
315
  appendEvent(executionId, 'authorized', normalizationNotes.length ? `normalized: ${normalizationNotes.join('; ')}` : undefined);
316
+ // Check after authorization awaits: an approval may have opened while this policy was pending.
317
+ const refusal = approvalRefusal({ executionId, run, descriptor: resolved.descriptor });
318
+ if (refusal)
319
+ return { refusal };
256
320
  return { allowed: resolved };
257
321
  }
258
322
  /**
@@ -268,13 +332,18 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
268
332
  async function confirmGate(request) {
269
333
  const { executionId } = request;
270
334
  let confirmation;
335
+ const state = { run: request.run, executionId, toolId: request.tool.id };
271
336
  try {
337
+ approvalStateChange({ ...state, pending: true });
272
338
  confirmation = await requestConfirmation(request);
273
339
  }
274
340
  catch {
275
341
  // Same reasoning as the authorization gate: terminal, recorded, and generic.
276
342
  return operationalFailure({ executionId, error: 'confirmation failed' });
277
343
  }
344
+ finally {
345
+ approvalStateChange({ ...state, pending: false });
346
+ }
278
347
  if (confirmation === 'confirm') {
279
348
  appendEvent(executionId, 'confirmed');
280
349
  return undefined;
@@ -353,6 +422,10 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
353
422
  if (controller.signal.aborted) {
354
423
  return terminal({ executionId, status: 'cancelled' });
355
424
  }
425
+ // Guidance may await I/O after admission; another call can open a confirmation in that gap.
426
+ const refusal = approvalRefusal({ executionId, run, descriptor });
427
+ if (refusal)
428
+ return refusal;
356
429
  const timeout = startTimeout(controller, descriptor.timeoutMs);
357
430
  // Cleanup (clear the timeout) is repeated at
358
431
  // each return below rather than centralized in a `finally` — a
@@ -363,8 +436,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
363
436
  // the catch). Repeating one call avoids leaving an uncoverable branch
364
437
  // behind instead of writing a contrived test or suppressing it.
365
438
  const cleanup = () => {
366
- if (timeout.handle)
367
- clearTimeout(timeout.handle);
439
+ timeout.stop();
440
+ approvalStateChange({ run, executionId, toolId: call.toolId, pending: false });
368
441
  };
369
442
  appendEvent(executionId, 'started');
370
443
  try {
@@ -379,6 +452,13 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
379
452
  signal: controller.signal,
380
453
  }, {
381
454
  ...surfaceOption(emitSurface),
455
+ onApprovalStateChange: ({ pending, decision }) => {
456
+ // The tab owns human confirmation; retain its outcome without opening a chat surface.
457
+ if (decision)
458
+ appendEvent(executionId, 'approval-recorded', `frontend approval: ${decision}`);
459
+ timeout.approval({ pending });
460
+ approvalStateChange({ run, executionId, toolId: call.toolId, pending });
461
+ },
382
462
  invokeTool: nestedInvoker({ principal, run, toolId: call.toolId, invocationRegistry: call.invocationRegistry, signal: controller.signal }, { emitSurface }),
383
463
  });
384
464
  cleanup();
@@ -389,7 +469,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
389
469
  return failureResult({ executionId, err, timedOut: timeout.timedOut(), aborted: controller.signal.aborted }, { normalizationNotes, guidance });
390
470
  }
391
471
  }
392
- async function execute({ principal, run, toolId, input }, { signal, emitSurface, invocationRegistry = registry } = {}) {
472
+ async function execute({ principal, run, toolId, input }, { signal, emitSurface, invocationRegistry = registry, requireConfirmation } = {}) {
393
473
  // Existence is checked separately from — and before — authorization so the audit record can
394
474
  // be opened before the (possibly slow, possibly throwing) authorization gate runs, matching
395
475
  // pre-authorizeToolInvocation timing: `requested` used to precede `policy.authorize`, and a
@@ -443,14 +523,14 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
443
523
  // `invocationRegistry.has({ toolId })` above already confirmed the tool exists, and `ToolRegistry` is
444
524
  // append-only (no unregister — see `createToolRegistry`'s doc), so `authorizeToolInvocation`
445
525
  // resolving the same `toolId` against the same `registry` cannot come back `undefined` here.
446
- const gate = admitAuthorized({ executionId, resolved: resolved, signal: controller.signal });
526
+ const gate = admitAuthorized({ executionId, resolved: resolved, signal: controller.signal, run });
447
527
  if ('refusal' in gate) {
448
528
  cleanupCancellation();
449
529
  return gate.refusal;
450
530
  }
451
531
  const admitted = gate.allowed;
452
532
  const { descriptor } = admitted;
453
- if (descriptor.requiresConfirmation) {
533
+ if (descriptor.requiresConfirmation || requireConfirmation) {
454
534
  const declined = await confirmGate({ executionId, principal, run, tool: descriptor, input: admitted.input });
455
535
  if (declined) {
456
536
  cleanupCancellation();