@jini-ai/daemon 0.6.10 → 0.6.11

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.
@@ -1,10 +1,12 @@
1
1
  import { buildAgentEnv } from './agent-env.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { supportsMessageRelay } from './message-relay.js';
2
4
  import { probeMcpBridge } from './mcp-bridge-probe.js';
3
5
  import { spawn as nodeSpawn, } from 'node:child_process';
4
6
  import { promises as fsPromises, } from 'node:fs';
5
7
  import { tmpdir, } from 'node:os';
6
8
  import { join, } from 'node:path';
7
- import { agentCapabilities, bindRuntimeAgentDef, defaultRuntimeState, getRuntimeModelDiscoveryDeps, userToolchainBinDirs, applyAgentLaunchEnv, ensureAgentCapabilities, resolveModelForLaunch, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
9
+ import { agentCapabilities, assertClaudeFilesystemSupport, claudeFilesystemRuntimePaths, claudeFilesystemSettings, validateFilesystemLaunch, bindRuntimeAgentDef, defaultRuntimeState, getRuntimeModelDiscoveryDeps, userToolchainBinDirs, applyAgentLaunchEnv, ensureAgentCapabilities, resolveModelForLaunch, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
8
10
  import { createCommandInvocation } from '@jini-ai/platform/command';
9
11
  import { collectProcessTreePids, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform/process';
10
12
  import { applyImagePromptDelivery, } from '../image-prompt-delivery.js';
@@ -337,6 +339,10 @@ function nativeBuildOptions({ input, permissionMode }, _options = {}) {
337
339
  /** Preserve explicit empty restrictions and settings: they have meaning to the native CLI. */
338
340
  function settingsBuildOptions({ input }, _options = {}) {
339
341
  return {
342
+ ...(input.filesystemRoot !== undefined ? { filesystemRoot: input.filesystemRoot } : {}),
343
+ ...(input.filesystemSharedTempRoots ? { filesystemSharedTempRoots: input.filesystemSharedTempRoots } : {}),
344
+ ...(input.filesystemTempDir ? { filesystemTempDir: input.filesystemTempDir } : {}),
345
+ ...(input.filesystemRuntimeReadPaths ? { filesystemRuntimeReadPaths: input.filesystemRuntimeReadPaths } : {}),
340
346
  ...(input.disallowedTools !== undefined ? { disallowedTools: input.disallowedTools } : {}),
341
347
  ...(input.allowedTools !== undefined ? { allowedTools: input.allowedTools } : {}),
342
348
  ...(input.settingSources !== undefined ? { settingSources: input.settingSources } : {}),
@@ -678,6 +684,15 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
678
684
  const claudeConfigDirIsolation = options.claudeConfigDirIsolation;
679
685
  const claudeConfigDirIsolationEnabled = options.claudeConfigDirIsolationEnabled ?? false;
680
686
  const promptAugmenter = options.promptAugmenter;
687
+ const filesystemTemps = new Map();
688
+ /** Remove only this run's private temp directory, on startup failure or child cleanup. */
689
+ async function cleanupFilesystemTemp({ runId }, _optional = {}) {
690
+ const path = filesystemTemps.get(runId);
691
+ if (!path)
692
+ return;
693
+ await fsPromises.rm(path, { recursive: true, force: true }).catch(() => console.warn('[agent-executor] private temp cleanup failed'));
694
+ filesystemTemps.delete(runId);
695
+ }
681
696
  /**
682
697
  * Transitions `runId` to `'failed'` (idempotent, never resumable — no
683
698
  * classifier exists, see index.ts module doc) then rejects with a typed
@@ -698,6 +713,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
698
713
  await lifecycle
699
714
  .emit({ runId: runId, input: { event: 'error', data: { message: `The assistant could not start: ${message.replace(/^AgentExecutor:\s*/, '')}` } } })
700
715
  .catch(() => undefined);
716
+ await cleanupFilesystemTemp({ runId }, {});
701
717
  await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
702
718
  throw new AgentExecutorError({ code: code, message: message });
703
719
  }
@@ -708,10 +724,12 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
708
724
  * finished or unknown run reads as `'not-running'`. Bounded by the number of live children.
709
725
  */
710
726
  const liveRunInputs = new Map();
727
+ const liveRunRelays = new Map();
728
+ const claimUserMessages = ({ runId, via }) => liveRunRelays.get(runId)?.claim({ via }) ?? [];
711
729
  /** `AgentExecutor.sendUserMessage()` — see that interface method's own doc. @complexity O(1). */
712
- async function sendUserMessage({ runId, text }, { imageContents } = {}) {
730
+ async function sendUserMessage({ runId, text, id = randomUUID(), attachmentIds }, { imageContents } = {}) {
713
731
  const deliver = liveRunInputs.get(runId);
714
- return deliver ? deliver(text, imageContents) : 'not-running';
732
+ return deliver ? deliver({ text, id, ...(attachmentIds ? { attachmentIds } : {}) }, imageContents ? { imageContents } : {}) : 'not-running';
715
733
  }
716
734
  /** Validates launch input and selects the definition synchronously before yielding. */
717
735
  async function prepareRunContext({ requiredArgs, optionalArgs }, _options = {}) {
@@ -731,6 +749,66 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
731
749
  return failBeforeSpawn({ runId: input.runId, code: 'NATIVE_TOOL_POLICY_UNSUPPORTED', message: 'This CLI has no native-tool policy and audit adapter. Choose a CLI with native-tool policy support.' });
732
750
  }
733
751
  }
752
+ /** Validate every directory after image augmentation, before model probes or resource staging.
753
+ * Unknown containment fails this run; O(n) filesystem checks for n attachment directories.
754
+ */
755
+ async function enforceFilesystemPaths({ input, imageDelivery }, _options = {}) {
756
+ if (input.filesystemRoot === undefined)
757
+ return;
758
+ try {
759
+ input.filesystemRoot = validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd,
760
+ ...(imageDelivery.extraAllowedDirs ? { extraAllowedDirs: imageDelivery.extraAllowedDirs } : {}),
761
+ ...(input.imagePaths ? { imagePaths: input.imagePaths } : {}),
762
+ }, {});
763
+ input.cwd = input.filesystemRoot;
764
+ }
765
+ catch {
766
+ return failBeforeSpawn({ runId: input.runId, code: 'FILESYSTEM_POLICY_INVALID', message: 'Folder-limited launch refused: invalid filesystem root, working directory or attachment path.' });
767
+ }
768
+ }
769
+ /** Apply supported confinement without removing providers; unknown versions keep the settings.
770
+ * Runtime and git config reads are explicit infrastructure exceptions; network is unchanged.
771
+ * O(1) metadata/staging I/O plus the cached, bounded version probe.
772
+ */
773
+ async function enforceFilesystemRuntime({ input, def, launch, spawnEnv }, _options = {}) {
774
+ if (input.filesystemRoot === undefined)
775
+ return;
776
+ try {
777
+ const verify = options.assertClaudeFilesystemSupport ?? assertClaudeFilesystemSupport;
778
+ const supported = await verify({ agentId: def.id, launchPath: launch.launchPath, env: spawnEnv }, {});
779
+ const runtimeDir = join(input.filesystemRoot, '.agent-runtime');
780
+ await fsPromises.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
781
+ // Refuse a runtime-folder symlink escaping the site before creating anything beneath it.
782
+ validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd, extraAllowedDirs: [runtimeDir] }, {});
783
+ input.filesystemTempDir = await fsPromises.realpath(await fsPromises.mkdtemp(join(runtimeDir, 'tmp-')));
784
+ filesystemTemps.set(input.runId, input.filesystemTempDir);
785
+ // Recheck after creation too: swapping the runtime symlink must never grant outside temp access.
786
+ validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd, extraAllowedDirs: [input.filesystemTempDir] }, {});
787
+ input.filesystemSharedTempRoots = [spawnEnv.TMPDIR, spawnEnv.TMP, spawnEnv.TEMP].filter((path) => Boolean(path));
788
+ spawnEnv.TMPDIR = input.filesystemTempDir;
789
+ spawnEnv.TMP = input.filesystemTempDir;
790
+ spawnEnv.TEMP = input.filesystemTempDir;
791
+ if (supported === false) {
792
+ input.filesystemNotice = 'files and shell are not folder-limited for this agent';
793
+ console.warn('[agent-executor] files and shell are not folder-limited for this agent');
794
+ // Personal Claude hooks/permissions stay excluded even on an unconfined CLI fallback.
795
+ if (def.id === 'claude')
796
+ input.settingSources = [];
797
+ delete input.filesystemRoot;
798
+ return;
799
+ }
800
+ if (supported === undefined)
801
+ console.warn('[agent-executor] filesystem capability unknown; applying read block and sandbox settings');
802
+ input.filesystemRuntimeReadPaths = claudeFilesystemRuntimePaths({ launchPath: launch.launchPath }, {});
803
+ // Validates host settings here so a malformed value fails cleanly; buildArgs merges again (idempotent).
804
+ input.settings = claudeFilesystemSettings({ options: input }, {});
805
+ // A login environment can otherwise force auto-memory back on despite the host setting.
806
+ spawnEnv.CLAUDE_CODE_DISABLE_AUTO_MEMORY = '1';
807
+ }
808
+ catch {
809
+ return failBeforeSpawn({ runId: input.runId, code: 'FILESYSTEM_POLICY_UNSUPPORTED', message: 'Folder-limited launch refused: invalid host settings or private runtime directory.' });
810
+ }
811
+ }
734
812
  function resolveRunOverlay({ input, def, imageDelivery }, _options = {}) {
735
813
  // Phase 8, hoisted deliberately above EVERY step that writes or sends the prompt. Four
736
814
  // different channels carry a prompt in this driver, and they do not all run at the same point:
@@ -751,7 +829,8 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
751
829
  // same overlay on every turn, not a first-turn-only one; a caller that needs finer-grained turn
752
830
  // numbering can track it itself and ignore this arg.
753
831
  const preStagingRuntimeContext = computeRuntimeContext({ preparedPromptFile: null, preparedLogFile: null, mcpBridge: null }, sessionRuntimeContext(input, {}));
754
- const systemPromptOverlay = computeSystemPromptOverlay(promptAugmenter, def.id);
832
+ const hostOverlay = computeSystemPromptOverlay(promptAugmenter, def.id);
833
+ const systemPromptOverlay = input.filesystemNotice ? `${hostOverlay ?? ''}\n${input.filesystemNotice}\n` : hostOverlay;
755
834
  const overlayDelivery = resolveSystemPromptOverlayDelivery({
756
835
  defId: def.id,
757
836
  systemPromptDelivery: def.systemPromptDelivery,
@@ -805,6 +884,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
805
884
  const formatForStdin = { streamFormat };
806
885
  const stdinHandle = isStdinDrivenFormat(formatForStdin)
807
886
  ? wireChildLifecycle({
887
+ ...(mcpBridge && supportsMessageRelay({ def }) ? { registerMessageRelay: (relay) => liveRunRelays.set(input.runId, relay) } : {}),
808
888
  ...(input.model ? { startingModel: input.model } : {}),
809
889
  ...(modelCatalog ? { modelCatalog } : {}),
810
890
  runId: input.runId,
@@ -831,8 +911,10 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
831
911
  // Registered with the lifecycle wiring above, before the spawn-confirmation await, so a child
832
912
  // that closes immediately never leaves a stale entry behind.
833
913
  const runId = input.runId;
834
- liveRunInputs.set(runId, stdinHandle ? stdinHandle.sendUserMessage : async () => 'unsupported');
835
- child.once('close', () => liveRunInputs.delete(runId));
914
+ liveRunInputs.set(runId, stdinHandle ? stdinHandle.sendUserMessage : async ({ text, id = randomUUID(), attachmentIds }, { imageContents } = {}) => {
915
+ return liveRunRelays.get(runId)?.enqueue({ id, text, hasAttachments: (attachmentIds?.length ?? 0) > 0 || (imageContents?.length ?? 0) > 0 }) ?? 'unsupported';
916
+ });
917
+ child.once('close', () => { liveRunInputs.delete(runId); liveRunRelays.delete(runId); });
836
918
  return stdinHandle;
837
919
  }
838
920
  async function dispatchAcpPrompt({ input, def, child, modelCatalog, cleanupStagedFiles, releaseStagedResources, mcpBridge, overlayDelivery }, _options = {}) {
@@ -859,6 +941,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
859
941
  }, deps: {
860
942
  lifecycle,
861
943
  attachAcpSession: attachAcpSessionFn,
944
+ ...(mcpBridge && supportsMessageRelay({ def }) ? { registerMessageRelay: (relay) => liveRunRelays.set(input.runId, relay) } : {}),
862
945
  onPermissionRequest: options.acpPermissionHandler,
863
946
  listProcessSnapshots: listProcessSnapshotsFn,
864
947
  collectProcessTreePids: collectProcessTreePidsFn,
@@ -939,8 +1022,10 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
939
1022
  // treatment (the double-delivery hazard this mechanism exists to avoid).
940
1023
  await enforceNativeToolPolicy({ input, def }, {});
941
1024
  const imageDelivery = await resolveImageDeliveryAndArgvBudget({ input: { runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, deps: { failBeforeSpawn } });
942
- const { systemPromptOverlay, overlayDelivery } = resolveRunOverlay({ input, def, imageDelivery }, {});
1025
+ await enforceFilesystemPaths({ input, imageDelivery }, {});
943
1026
  const { launch, spawnEnv } = await resolveRunLaunch({ input, def }, {});
1027
+ await enforceFilesystemRuntime({ input, def, launch, spawnEnv }, {});
1028
+ const { systemPromptOverlay, overlayDelivery } = resolveRunOverlay({ input, def, imageDelivery }, {});
944
1029
  const modelCatalog = await selectRunModel({ input, def, launch, spawnEnv }, {});
945
1030
  // Fill the def's `--help` capability gate before `buildArgs` reads it. Without this, only a
946
1031
  // host that happened to call `detectAgents` in THIS process ever had the gate filled, so e.g.
@@ -998,6 +1083,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
998
1083
  */
999
1084
  let preparedSystemPromptOverlayFile = null;
1000
1085
  const cleanupStagedFiles = async () => {
1086
+ await cleanupFilesystemTemp({ runId: input.runId }, {});
1001
1087
  if (preparedPromptFile)
1002
1088
  await preparedPromptFile.cleanup();
1003
1089
  if (preparedLogFile)
@@ -1130,6 +1216,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
1130
1216
  }
1131
1217
  catch (error) {
1132
1218
  liveRunInputs.delete(runId);
1219
+ liveRunRelays.delete(runId);
1133
1220
  throw error;
1134
1221
  }
1135
1222
  // Now — and only now — is there a live process that could consume the locked side effect, so
@@ -1146,6 +1233,6 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
1146
1233
  }
1147
1234
  dispatchStdinPrompt({ input, def, child, overlayDelivery, imageDelivery, stdinHandle }, {});
1148
1235
  }
1149
- return { run, sendUserMessage };
1236
+ return { run, sendUserMessage, claimUserMessages };
1150
1237
  }
1151
1238
  //# sourceMappingURL=launch.js.map
@@ -0,0 +1,23 @@
1
+ import type { RunAgentPayload } from '@jini-ai/protocol';
2
+ /** Per-process receipts and task projection for the existing stdin lifecycle. O(p + t) retained
3
+ * state for pending inputs and observed tasks; each frame is O(frame size + affected tasks).
4
+ * The injected emitter uses the lifecycle's existing FIFO, preserving ordering with tool output.
5
+ */
6
+ export declare function createLiveSessionState({ emit }: {
7
+ emit: (payload: RunAgentPayload) => void;
8
+ }, _options?: {}): {
9
+ queue({ id, text }: {
10
+ id: string;
11
+ text: string;
12
+ }): void;
13
+ observe: ({ line }: {
14
+ line: string;
15
+ }) => {
16
+ result: boolean;
17
+ startedInput: boolean;
18
+ };
19
+ hasPendingInput: () => boolean;
20
+ backgroundTaskCount: () => number;
21
+ endSession(): void;
22
+ };
23
+ //# sourceMappingURL=live-session-state.d.ts.map
@@ -0,0 +1,155 @@
1
+ import { isRecord } from './values.js';
2
+ /** Parse only records; malformed stdout remains the stream parser's responsibility. */
3
+ function frameRecord(line) {
4
+ try {
5
+ const value = JSON.parse(line);
6
+ return isRecord(value) ? value : undefined;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ /** UUID attribution is a consumption receipt; an echoed user line or HTTP 202 is not. */
13
+ function consumedIds(frame) {
14
+ if (!['assistant', 'stream_event', 'result'].includes(String(frame.type)))
15
+ return [];
16
+ const ids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids : [frame.user_message_uuid];
17
+ return ids.filter((id) => typeof id === 'string');
18
+ }
19
+ /** Preserve unknown task outcomes rather than guessing success or who stopped them. */
20
+ function taskStatus(value) {
21
+ if (value === 'killed')
22
+ return 'stopped';
23
+ if (value === 'running' || value === 'completed' || value === 'failed' || value === 'stopped')
24
+ return value;
25
+ return 'unknown';
26
+ }
27
+ /** Per-process receipts and task projection for the existing stdin lifecycle. O(p + t) retained
28
+ * state for pending inputs and observed tasks; each frame is O(frame size + affected tasks).
29
+ * The injected emitter uses the lifecycle's existing FIFO, preserving ordering with tool output.
30
+ */
31
+ export function createLiveSessionState({ emit }, _options = {}) {
32
+ const pending = new Map();
33
+ // Ids written since the last result. Only these and the CLI's own queued_turn_count hold stdin:
34
+ // a CLI that never attributes a receipt must not keep the run open forever. Closing after a
35
+ // result is safe for a line still in the pipe: the CLI answers everything written before EOF.
36
+ const unanswered = new Set();
37
+ const tasks = new Map();
38
+ let queuedTurns = 0;
39
+ let backgroundTasks = 0;
40
+ function publishTask(task) {
41
+ tasks.set(task.id, task);
42
+ const { local: _local, ...payload } = task;
43
+ emit(payload);
44
+ }
45
+ function taskDetails(frame, previous) {
46
+ const patch = isRecord(frame.patch) ? frame.patch : frame;
47
+ const description = typeof patch.description === 'string' ? patch.description : previous?.description;
48
+ const detail = typeof frame.summary === 'string' ? frame.summary : previous?.detail;
49
+ const local = frame.task_type === 'local_agent' || typeof frame.subagent_type === 'string' || previous?.local === true;
50
+ return { local, ...(description === undefined ? {} : { description }), ...(detail === undefined ? {} : { detail }) };
51
+ }
52
+ function nextTaskStatus(frame, previous) {
53
+ if (frame.subtype === 'task_started')
54
+ return 'running';
55
+ const patch = isRecord(frame.patch) ? frame.patch : frame;
56
+ if (patch.status === undefined)
57
+ return previous?.status ?? 'unknown';
58
+ return taskStatus(patch.status);
59
+ }
60
+ /** Older task runs and duplicate starts cannot overwrite a confirmed terminal receipt. */
61
+ function staleTaskUpdate(previous, taskRunId, status) {
62
+ if (!previous)
63
+ return false;
64
+ // The CLI mints run ids as zero-padded base36 time plus random hex, so they sort by start.
65
+ if (previous.taskRunId && taskRunId && taskRunId < previous.taskRunId)
66
+ return true;
67
+ const terminal = ['completed', 'failed', 'stopped'].includes(previous.status);
68
+ return terminal && previous.taskRunId === taskRunId && status === 'running';
69
+ }
70
+ function updateTask(frame) {
71
+ if (typeof frame.task_id !== 'string')
72
+ return;
73
+ const previous = tasks.get(frame.task_id);
74
+ const taskRunId = typeof frame.run_id === 'string' ? frame.run_id : previous?.taskRunId;
75
+ const status = nextTaskStatus(frame, previous);
76
+ // Resumed tasks reuse task_id. A late frame from an older run cannot stop its replacement.
77
+ if (staleTaskUpdate(previous, taskRunId, status))
78
+ return;
79
+ publishTask({ type: 'background_task', id: frame.task_id, status, ...taskDetails(frame, previous),
80
+ ...(taskRunId === undefined ? {} : { taskRunId }) });
81
+ }
82
+ function updateMembership(frame) {
83
+ if (!Array.isArray(frame.tasks))
84
+ return;
85
+ backgroundTasks = frame.tasks.length;
86
+ const listed = new Set();
87
+ for (const task of frame.tasks) {
88
+ if (!isRecord(task))
89
+ continue;
90
+ if (typeof task.task_id === 'string')
91
+ listed.add(task.task_id);
92
+ updateTask({ ...task, subtype: 'task_started' });
93
+ }
94
+ // Level and edge notifications have unspecified ordering. Disappearance proves neither
95
+ // completion nor cancellation; a later task_notification supplies the actual outcome.
96
+ for (const task of tasks.values()) {
97
+ if (task.status === 'running' && !listed.has(task.id))
98
+ publishTask({ ...task, status: 'unknown', detail: 'status unknown' });
99
+ }
100
+ }
101
+ function consume(frame) {
102
+ let consumed = false;
103
+ for (const id of consumedIds(frame)) {
104
+ const text = pending.get(id);
105
+ if (text === undefined)
106
+ continue;
107
+ pending.delete(id);
108
+ unanswered.delete(id);
109
+ emit({ type: 'user_message', id, text, deliveryState: 'consumed' });
110
+ consumed = true;
111
+ }
112
+ return consumed;
113
+ }
114
+ function observe({ line }) {
115
+ const frame = frameRecord(line);
116
+ if (!frame)
117
+ return { result: false, startedInput: false };
118
+ const consumed = consume(frame);
119
+ if (frame.type === 'result') {
120
+ queuedTurns = typeof frame.queued_turn_count === 'number' ? Math.max(0, frame.queued_turn_count) : 0;
121
+ unanswered.clear();
122
+ }
123
+ if (frame.type === 'system')
124
+ observeSystem(frame);
125
+ return { result: frame.type === 'result', startedInput: consumed && frame.type !== 'result' };
126
+ }
127
+ function observeSystem(frame) {
128
+ if (frame.subtype === 'background_tasks_changed')
129
+ updateMembership(frame);
130
+ if (['task_started', 'task_notification', 'task_updated'].includes(String(frame.subtype)))
131
+ updateTask(frame);
132
+ }
133
+ return {
134
+ queue({ id, text }) {
135
+ pending.set(id, text);
136
+ unanswered.add(id);
137
+ emit({ type: 'user_message', id, text, deliveryState: 'queued' });
138
+ },
139
+ observe,
140
+ hasPendingInput: () => unanswered.size > 0 || queuedTurns > 0,
141
+ backgroundTaskCount: () => backgroundTasks,
142
+ endSession() {
143
+ for (const task of tasks.values()) {
144
+ if (task.status !== 'running' && task.status !== 'unknown')
145
+ continue;
146
+ // A task already missing from membership may have completed without a receipt. Only
147
+ // a still-running, process-local subagent is known to stop with this session.
148
+ const stopped = task.local && task.status === 'running';
149
+ publishTask({ ...task, status: stopped ? 'stopped' : 'unknown',
150
+ detail: stopped ? 'Stopped because the agent session ended' : 'status unknown' });
151
+ }
152
+ },
153
+ };
154
+ }
155
+ //# sourceMappingURL=live-session-state.js.map
@@ -34,8 +34,9 @@ export declare function bridgeUnavailableMessage(status: string, probe?: McpBrid
34
34
  export declare function buildMcpJsonServerEntry({ runId, options }: {
35
35
  readonly runId: string;
36
36
  readonly options: Pick<McpJsonInjectionOptions, 'command' | 'args' | 'daemonUrl' | 'env' | 'diagnosticToolIds' | 'startupToolIds'>;
37
- }, { credential }?: {
37
+ }, { credential, messageRelayNonce }?: {
38
38
  readonly credential?: string;
39
+ readonly messageRelayNonce?: string;
39
40
  }): McpJsonServerEntry;
40
41
  /**
41
42
  * Merges {@link JINI_MCP_SERVER_KEY} into an existing `.mcp.json`'s `mcpServers` map, preserving
@@ -235,6 +236,7 @@ export declare function buildMcpBridgeDelivery(input: {
235
236
  readonly strategy: RuntimeAgentDef['externalMcpInjection'];
236
237
  readonly options: McpJsonInjectionOptions | undefined;
237
238
  readonly credential: string | undefined;
239
+ readonly messageRelayNonce?: string;
238
240
  }): McpBridgeDelivery | null;
239
241
  export declare function defaultRemoveMcpJsonFile({ path }: {
240
242
  readonly path: string;
@@ -1,4 +1,6 @@
1
1
  import { promises as fsPromises, } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { supportsMessageRelay } from './message-relay.js';
2
4
  import { ensureAntigravityMcpPermission } from './antigravity-permissions.js';
3
5
  import { homedir, tmpdir, } from 'node:os';
4
6
  import { join, resolve, } from 'node:path';
@@ -46,7 +48,7 @@ export function bridgeUnavailableMessage(status, probe) {
46
48
  * entirely. Omitting produces byte-identical output to before this parameter existed.
47
49
  * @complexity O(t) output space/time for t launch environment and registered ID bytes.
48
50
  */
49
- export function buildMcpJsonServerEntry({ runId, options }, { credential } = {}) {
51
+ export function buildMcpJsonServerEntry({ runId, options }, { credential, messageRelayNonce } = {}) {
50
52
  const hostEnv = Object.entries(options.env ?? {}).filter(([key]) => !key.startsWith('JINI_'));
51
53
  return {
52
54
  command: options.command,
@@ -58,6 +60,7 @@ export function buildMcpJsonServerEntry({ runId, options }, { credential } = {})
58
60
  ...(options.diagnosticToolIds ? { JINI_DIAGNOSTIC_TOOL_IDS: JSON.stringify(options.diagnosticToolIds()) } : {}),
59
61
  ...(options.startupToolIds ? { JINI_STARTUP_TOOL_IDS: JSON.stringify(options.startupToolIds({ runId })) } : {}),
60
62
  ...(credential !== undefined ? { JINI_DAEMON_TOKEN: credential } : {}),
63
+ ...(messageRelayNonce ? { JINI_MESSAGE_RELAY: '1', JINI_MESSAGE_RELAY_NONCE: messageRelayNonce } : {}),
61
64
  },
62
65
  };
63
66
  }
@@ -479,7 +482,10 @@ export function buildMcpBridgeDelivery(input) {
479
482
  const { cwd, runId, strategy, options, credential } = input;
480
483
  if (options === undefined || strategy === undefined)
481
484
  return null;
482
- const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options }, credential === undefined ? {} : { credential });
485
+ const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options }, {
486
+ ...(credential === undefined ? {} : { credential }),
487
+ ...(input.messageRelayNonce ? { messageRelayNonce: input.messageRelayNonce } : {}),
488
+ });
483
489
  switch (strategy) {
484
490
  case 'claude-mcp-json':
485
491
  return { kind: 'claude-mcp-json', mcpJsonPath: mcpJsonPathForRun(cwd, runId), serverEntry };
@@ -789,6 +795,7 @@ export async function resolveMcpBridgeForRun({ input, deps }) {
789
795
  strategy: input.def.externalMcpInjection,
790
796
  options: deps.mcpJsonInjection,
791
797
  credential,
798
+ ...(supportsMessageRelay({ def: input.def }) ? { messageRelayNonce: randomBytes(24).toString('hex') } : {}),
792
799
  });
793
800
  if (input.def.id === 'antigravity' && delivery?.kind === 'env-passthrough') {
794
801
  await (deps.ensureAntigravityMcpPermission ?? ensureAntigravityMcpPermission)({
@@ -0,0 +1,39 @@
1
+ import type { RuntimeAgentDef } from '@jini-ai/agent-runtime';
2
+ import type { RunAgentPayload, RelayedUserMessage, MessageRelayVia } from '@jini-ai/protocol';
3
+ export interface RunMessageRelay {
4
+ enqueue(input: {
5
+ id: string;
6
+ text: string;
7
+ hasAttachments: boolean;
8
+ }): 'delivered' | 'not-running';
9
+ claim(input: {
10
+ via: MessageRelayVia;
11
+ }): readonly RelayedUserMessage[];
12
+ close(): void;
13
+ }
14
+ /**
15
+ * Whether a def's live process can take another user message mid-run: only a stream-json stdin
16
+ * transport keeps stdin open for more JSONL user lines (see `RuntimeAgentDef.promptInputFormat`).
17
+ * Deliberately not `resolveContinuationTransport`: that answers how a tool result gets back, and
18
+ * reports `'mcp-callback'` for Claude even though its stdin takes user lines all the same.
19
+ * Native stream-json input wins even when the engine also has MCP.
20
+ */
21
+ export declare function acceptsMidRunUserMessages({ def }: {
22
+ def: RuntimeAgentDef;
23
+ }, _options?: {}): boolean;
24
+ /** A definition without an injected MCP bridge must retain next-turn fallback. */
25
+ export declare function supportsMessageRelay({ def }: {
26
+ def: RuntimeAgentDef;
27
+ }, _options?: {}): boolean;
28
+ /** One synchronous FIFO claim per process: parallel tool results get disjoint prefixes.
29
+ * The browser retains the fallback copy until the emitted consumption receipt arrives.
30
+ * @param emit - Enqueues receipts on the driver's existing lifecycle FIFO; never waits on I/O.
31
+ * @returns A text-only inbox, closed synchronously before the driver's terminal event.
32
+ * @complexity O(n) retained messages; each claim scans its prefix and removes it in one splice.
33
+ */
34
+ export declare function createMessageRelay({ emit }: {
35
+ emit: (payload: RunAgentPayload) => void;
36
+ }, { maxChars }?: {
37
+ maxChars?: number;
38
+ }): RunMessageRelay;
39
+ //# sourceMappingURL=message-relay.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether a def's live process can take another user message mid-run: only a stream-json stdin
3
+ * transport keeps stdin open for more JSONL user lines (see `RuntimeAgentDef.promptInputFormat`).
4
+ * Deliberately not `resolveContinuationTransport`: that answers how a tool result gets back, and
5
+ * reports `'mcp-callback'` for Claude even though its stdin takes user lines all the same.
6
+ * Native stream-json input wins even when the engine also has MCP.
7
+ */
8
+ export function acceptsMidRunUserMessages({ def }, _options = {}) {
9
+ return def.promptViaStdin === true && def.promptInputFormat === 'stream-json';
10
+ }
11
+ /** A definition without an injected MCP bridge must retain next-turn fallback. */
12
+ export function supportsMessageRelay({ def }, _options = {}) {
13
+ return def.externalMcpInjection !== undefined && !acceptsMidRunUserMessages({ def });
14
+ }
15
+ /** One synchronous FIFO claim per process: parallel tool results get disjoint prefixes.
16
+ * The browser retains the fallback copy until the emitted consumption receipt arrives.
17
+ * @param emit - Enqueues receipts on the driver's existing lifecycle FIFO; never waits on I/O.
18
+ * @returns A text-only inbox, closed synchronously before the driver's terminal event.
19
+ * @complexity O(n) retained messages; each claim scans its prefix and removes it in one splice.
20
+ */
21
+ export function createMessageRelay({ emit }, { maxChars = 32_000 } = {}) {
22
+ const pending = [];
23
+ let seq = 0;
24
+ let closed = false;
25
+ return {
26
+ enqueue({ id, text, hasAttachments }) {
27
+ if (closed)
28
+ return 'not-running';
29
+ pending.push({ id, text, hasAttachments, seq: ++seq });
30
+ emit({ type: 'user_message', id, text, deliveryState: 'queued' });
31
+ return 'delivered';
32
+ },
33
+ claim({ via }) {
34
+ if (closed)
35
+ return [];
36
+ let count = 0;
37
+ let chars = 0;
38
+ for (const message of pending) {
39
+ if (message.hasAttachments)
40
+ break;
41
+ const size = JSON.stringify({ id: message.id, seq: message.seq, text: message.text }).length;
42
+ // Whole messages only; a single oversized message is delivered alone.
43
+ if (count > 0 && chars + size > maxChars)
44
+ break;
45
+ chars += size;
46
+ count++;
47
+ }
48
+ return pending.splice(0, count).map(({ id, seq, text }) => {
49
+ emit({ type: 'user_message', id, text, deliveryState: 'consumed', via });
50
+ return { id, seq, text };
51
+ });
52
+ },
53
+ close() { closed = true; pending.length = 0; },
54
+ };
55
+ }
56
+ //# sourceMappingURL=message-relay.js.map
@@ -55,6 +55,8 @@ export interface ReadOnlyToolMessages {
55
55
  }
56
56
  export interface ReadOnlyToolCheckRequired {
57
57
  readonly toolId: string;
58
+ /** Diagnostic selector only; never changes which registration is checked or executed. */
59
+ readonly input?: unknown;
58
60
  readonly registry: Pick<ToolRegistry, 'list'> | undefined;
59
61
  readonly messages: ReadOnlyToolMessages;
60
62
  }
@@ -94,8 +96,9 @@ export declare function readOnlyToolRefusalMessage({ toolId, messages }: {
94
96
  /**
95
97
  * Single read-only registration decision, shared by routes and inner dispatch.
96
98
  * @returns Null for an explicitly read-only registration, otherwise host refusal text.
99
+ * @complexity O(n) for known tools; unknown-tool diagnostics add bounded name scoring and O(n log n) sorting.
97
100
  */
98
- export declare function checkReadOnlyTool({ toolId, registry, messages }: ReadOnlyToolCheckRequired, _optional?: Record<string, never>): string | null;
101
+ export declare function checkReadOnlyTool({ toolId, input, registry, messages }: ReadOnlyToolCheckRequired, _optional?: Record<string, never>): string | null;
99
102
  /**
100
103
  * THE read-only dispatch decision for any tool id a consumer's own `ToolExecutor` composition is
101
104
  * about to invoke — the outer call the route already checked, or a nested one a decorator issues
@@ -110,8 +113,9 @@ export declare function checkReadOnlyTool({ toolId, registry, messages }: ReadOn
110
113
  * @param required.registry - Descriptors to resolve `toolId` against; `undefined` ⇒ refuse (an
111
114
  * unverifiable constrained dispatch is refused, never waived, matching `checkReadOnlyTool`).
112
115
  * @returns `null` when the dispatch may proceed, otherwise the refusal text to report.
113
- * @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a constrained
114
- * one, since `ToolRegistry` exposes enumeration rather than lookup by id.
116
+ * @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a known tool,
117
+ * since `ToolRegistry` exposes enumeration rather than lookup by id. Unknown ids also incur
118
+ * the bounded similarity scoring and sorting cost documented by `toolCandidates`.
115
119
  */
116
120
  export declare function refuseNonReadOnlyDispatch(required: ReadOnlyToolCheckRequired & {
117
121
  readonly principal: Principal;
@@ -147,7 +151,8 @@ export declare function readOnlyRemedyRefusalMessage({ refusal, formatMessage }:
147
151
  *
148
152
  * @returns A drop-in `ToolExecutor`; `resumeConfirmation`/`cancel`/`getAuditRecord` delegate
149
153
  * straight through.
150
- * @complexity One extra property read per unconstrained call; one registry scan per constrained one.
154
+ * @complexity One extra property read per unconstrained call; one registry scan per constrained one,
155
+ * plus bounded similarity scoring and sorting for an unknown id.
151
156
  */
152
157
  export declare function withReadOnlyToolConstraint({ inner, registry, idGenerator, messages }: ReadOnlyToolConstraintRequired, _optional?: Record<string, never>): ToolExecutor;
153
158
  //# sourceMappingURL=read-only-tools.d.ts.map
@@ -60,14 +60,15 @@ export function readOnlyToolRefusalMessage({ toolId, messages }, _optional = {})
60
60
  /**
61
61
  * Single read-only registration decision, shared by routes and inner dispatch.
62
62
  * @returns Null for an explicitly read-only registration, otherwise host refusal text.
63
+ * @complexity O(n) for known tools; unknown-tool diagnostics add bounded name scoring and O(n log n) sorting.
63
64
  */
64
- export function checkReadOnlyTool({ toolId, registry, messages }, _optional = {}) {
65
+ export function checkReadOnlyTool({ toolId, input, registry, messages }, _optional = {}) {
65
66
  if (registry === undefined)
66
67
  return messages.unverifiableMessage;
67
68
  const tools = registry.list({});
68
69
  const descriptor = tools.find((candidate) => candidate.id === toolId);
69
70
  if (!descriptor)
70
- return messages.unknownToolMessage({ toolId, candidates: toolCandidates({ toolId, tools }) });
71
+ return messages.unknownToolMessage({ toolId, candidates: toolCandidates({ toolId, tools, input }) });
71
72
  if (isReadOnlyTool({ descriptor }))
72
73
  return null;
73
74
  return readOnlyToolRefusalMessage({ toolId, messages });
@@ -86,8 +87,9 @@ export function checkReadOnlyTool({ toolId, registry, messages }, _optional = {}
86
87
  * @param required.registry - Descriptors to resolve `toolId` against; `undefined` ⇒ refuse (an
87
88
  * unverifiable constrained dispatch is refused, never waived, matching `checkReadOnlyTool`).
88
89
  * @returns `null` when the dispatch may proceed, otherwise the refusal text to report.
89
- * @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a constrained
90
- * one, since `ToolRegistry` exposes enumeration rather than lookup by id.
90
+ * @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a known tool,
91
+ * since `ToolRegistry` exposes enumeration rather than lookup by id. Unknown ids also incur
92
+ * the bounded similarity scoring and sorting cost documented by `toolCandidates`.
91
93
  */
92
94
  export function refuseNonReadOnlyDispatch(required, _optional = {}) {
93
95
  if (!principalIsReadOnlyConstrained({ principal: required.principal }))
@@ -127,13 +129,14 @@ export function readOnlyRemedyRefusalMessage({ refusal, formatMessage }, _option
127
129
  *
128
130
  * @returns A drop-in `ToolExecutor`; `resumeConfirmation`/`cancel`/`getAuditRecord` delegate
129
131
  * straight through.
130
- * @complexity One extra property read per unconstrained call; one registry scan per constrained one.
132
+ * @complexity One extra property read per unconstrained call; one registry scan per constrained one,
133
+ * plus bounded similarity scoring and sorting for an unknown id.
131
134
  */
132
135
  export function withReadOnlyToolConstraint({ inner, registry, idGenerator, messages }, _optional = {}) {
133
136
  return {
134
137
  execute: async (requiredArgs, optionalArgs = {}) => {
135
- const { principal, toolId } = requiredArgs;
136
- const refusal = refuseNonReadOnlyDispatch({ principal, toolId, registry, messages });
138
+ const { principal, toolId, input } = requiredArgs;
139
+ const refusal = refuseNonReadOnlyDispatch({ principal, toolId, input, registry, messages });
137
140
  // Deny before inner: no handler or kernel audit record exists. Use the authorization result
138
141
  // union instead of throwing, and mint a denial ID because there is no inner execution ID.
139
142
  if (refusal !== null)