@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -1,9 +1,12 @@
1
1
  import { JsonRpcProcess } from './jsonrpc.mjs';
2
+ import { createCodexAccountState } from '../codex-account.mjs';
2
3
  import { createNativeInteractions } from './interactions.mjs';
4
+ import { codexUsage } from './telemetry.mjs';
5
+ import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
3
6
  import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../codex-interactions.mjs';
4
7
 
5
8
  const CAPABILITIES = Object.freeze({ create: true, resume: true, send: true, observe: true,
6
- steer: true, interrupt: true, close: true, fork: false });
9
+ steer: true, interrupt: true, close: true, fork: false, accountRead: true });
7
10
  const TERMINAL_TURN_STATUSES = new Set(['completed', 'interrupted', 'failed']);
8
11
 
9
12
  function adapterError(code, message) {
@@ -14,6 +17,7 @@ function adapterError(code, message) {
14
17
  export function createCodexAdapter(options = {}) {
15
18
  const { binary = 'codex', cwd, env, timeoutMs = 30_000, onEvent = () => {},
16
19
  rpcFactory = (config) => new JsonRpcProcess(config) } = options;
20
+ const cwdBinding = cwd ? captureSelectedCwdIdentity(cwd, options.expectedCwdIdentity) : null;
17
21
  const state = { provider: 'codex', sessionId: null, turnId: null, status: 'new', owned: true };
18
22
  const terminalTurns = new Set();
19
23
  const interruptedTurns = new Set();
@@ -24,6 +28,8 @@ export function createCodexAdapter(options = {}) {
24
28
  let admission = Promise.resolve();
25
29
  let pendingSubmissionId;
26
30
  let closePromise;
31
+ const account = createCodexAccountState({ request: (method, params) => rpc.request(method, params),
32
+ onChange: value => emit({ type: 'account', account: value }) });
27
33
  const capabilities = { ...CAPABILITIES, approvals: Boolean(options.interactive), userInput: Boolean(options.interactive), mcp: Boolean(options.mcpServers) };
28
34
  const interactions = createNativeInteractions({ executorId: options.executorId,
29
35
  isActive: (sessionId, turnId) => !closed && sessionId === state.sessionId && turnId === state.turnId && !terminalTurns.has(turnId) && !interruptedTurns.has(turnId) && !['error', 'uncertain', 'closed'].includes(state.status),
@@ -55,10 +61,13 @@ export function createCodexAdapter(options = {}) {
55
61
  }
56
62
 
57
63
  function notification(method, params = {}) {
58
- if (closed || !state.sessionId || params.threadId !== state.sessionId) return;
64
+ if (closed || account.notification(method, params) || !state.sessionId || params.threadId !== state.sessionId) return;
59
65
  const turnId = params.turn?.id ?? params.turnId;
60
66
  const submissionId = turnSubmissions.get(turnId) ?? pendingSubmissionId;
61
- if (method === 'turn/started' && turnId && !terminalTurns.has(turnId)) {
67
+ if (method === 'thread/tokenUsage/updated') {
68
+ const metrics = codexUsage(params);
69
+ if (metrics) { state.metrics = metrics; emit({ type: 'usage', metrics, turnId }); }
70
+ } else if (method === 'turn/started' && turnId && !terminalTurns.has(turnId)) {
62
71
  if (submissionId) turnSubmissions.set(turnId, submissionId);
63
72
  state.turnId = turnId;
64
73
  status('busy');
@@ -119,6 +128,8 @@ export function createCodexAdapter(options = {}) {
119
128
 
120
129
  function exited(details = {}) {
121
130
  if (closed) return;
131
+ cwdBinding?.release();
132
+ account.close();
122
133
  interactions.expire();
123
134
  state.turnId = null;
124
135
  status('error');
@@ -128,6 +139,7 @@ export function createCodexAdapter(options = {}) {
128
139
 
129
140
  function transportError(error) {
130
141
  if (closed) return;
142
+ account.close();
131
143
  interactions.expire();
132
144
  status('error');
133
145
  emit({ type: 'error', code: error.code ?? 'NATIVE_TRANSPORT_ERROR', text: error.message,
@@ -143,6 +155,7 @@ export function createCodexAdapter(options = {}) {
143
155
  }
144
156
  status('opening');
145
157
  try {
158
+ cwdBinding?.assertUnchanged();
146
159
  rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env, timeoutMs,
147
160
  onNotification: notification, onRequest: serverRequest, onExit: exited, onError: transportError });
148
161
  await rpc.start();
@@ -156,12 +169,16 @@ export function createCodexAdapter(options = {}) {
156
169
  const params = { sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
157
170
  ...(Object.keys(config).length ? { config } : {}),
158
171
  ...(cwd ? { cwd } : {}), ...(model ? { model } : {}), ...(sessionId ? { threadId: sessionId } : {}) };
172
+ cwdBinding?.assertUnchanged();
159
173
  const response = await rpc.request(sessionId ? 'thread/resume' : 'thread/start', params);
160
174
  if (!response?.thread?.id || (sessionId && response.thread.id !== sessionId)) {
161
175
  throw adapterError('NATIVE_PROTOCOL_ERROR', 'Codex returned an invalid native thread identity.');
162
176
  }
163
177
  if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
164
178
  state.sessionId = response.thread.id;
179
+ state.runtimeMetadata = { ...(typeof response.model === 'string' ? { model: response.model } : {}),
180
+ ...(typeof response.approvalPolicy === 'string' ? { permissionMode: response.approvalPolicy } : {}) };
181
+ emit({ type: 'metadata', runtimeMetadata: state.runtimeMetadata });
165
182
  const activeTurn = response.thread.turns?.findLast((turn) => turn.status === 'inProgress');
166
183
  state.turnId = activeTurn?.id ?? null;
167
184
  status(state.turnId ? 'busy' : 'idle');
@@ -229,14 +246,28 @@ export function createCodexAdapter(options = {}) {
229
246
  function close() {
230
247
  if (closePromise) return closePromise;
231
248
  closed = true;
249
+ account.close();
232
250
  interactions.expire();
233
251
  state.turnId = null;
234
- status('closed');
235
- closePromise = Promise.resolve().then(() => rpc?.close()).then(() => snapshot());
252
+ status('closing');
253
+ closePromise = Promise.resolve().then(() => rpc?.close()).then(() => {
254
+ cwdBinding?.release();
255
+ status('closed');
256
+ return snapshot();
257
+ }).catch((error) => {
258
+ cwdBinding?.release();
259
+ // Keep admissions revoked while allowing owned-process cleanup to retry.
260
+ status('uncertain');
261
+ closePromise = null;
262
+ throw error;
263
+ });
236
264
  return closePromise;
237
265
  }
238
266
 
239
- function snapshot() { return { ...state, executorId: interactions.executorId, capabilities: { ...capabilities }, pendingApprovals: interactions.snapshot() }; }
267
+ function snapshot() { return { ...state, executorId: interactions.executorId, capabilities: { ...capabilities },
268
+ account: account.snapshot(), pendingApprovals: interactions.snapshot() }; }
269
+
270
+ async function readAccount() { requireOpen(); await account.refresh(); requireOpen(); return account.snapshot(); }
240
271
 
241
- return { capabilities, open, send, interrupt, close, snapshot, respond: interactions.respond };
272
+ return { capabilities, open, send, interrupt, close, snapshot, readAccount, respond: interactions.respond };
242
273
  }
@@ -2,6 +2,8 @@ import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { createNativeInteractions } from './interactions.mjs';
4
4
  import { JsonRpcProcess } from './jsonrpc.mjs';
5
+ import { acpUsage } from './telemetry.mjs';
6
+ import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
5
7
 
6
8
  export const DSH_ACP_BASELINE_VERSION = '0.2.0-rc.2';
7
9
 
@@ -16,6 +18,7 @@ export const DSH_ACP_BASELINE_VERSION = '0.2.0-rc.2';
16
18
  export function createDshAcpAdapter(options = {}) {
17
19
  const cwd = options.cwd || process.cwd();
18
20
  if (!path.isAbsolute(cwd)) throw failure('NATIVE_BAD_CWD', 'ACP requires an absolute workspace directory');
21
+ const cwdBinding = captureSelectedCwdIdentity(cwd, options.expectedCwdIdentity);
19
22
  const capabilities = {
20
23
  create: true, resume: false, send: true, observe: true,
21
24
  steer: false, interrupt: true, close: false, fork: false, approvals: Boolean(options.interactive), mcp: Boolean(options.mcpServers)
@@ -26,9 +29,13 @@ export function createDshAcpAdapter(options = {}) {
26
29
  let active = null;
27
30
  let opening = null;
28
31
  let openingIdentity = null;
29
- let closing = null;
32
+ let closing = false;
33
+ let closePromise = null;
34
+ let runtimeDisposalStarted = false;
35
+ let runtimeExited = false;
30
36
  let runtimeDisposed = false;
31
37
  let lastError = null;
38
+ let metrics = null, runtimeMetadata = {};
32
39
  const receipts = new Map();
33
40
  const toolCalls = new Map(), toolContextWaiters = new Set();
34
41
  const interactions = createNativeInteractions({ executorId: options.executorId,
@@ -50,6 +57,7 @@ export function createDshAcpAdapter(options = {}) {
50
57
  cwd, sessionId, status,
51
58
  turnId: active?.turnId || null,
52
59
  submissionId: active?.submissionId || null,
60
+ metrics, runtimeMetadata: { ...runtimeMetadata },
53
61
  capabilities: { ...capabilities }, executorId: interactions.executorId, pendingApprovals: interactions.snapshot(),
54
62
  error: lastError ? { ...lastError } : null
55
63
  };
@@ -89,6 +97,12 @@ export function createDshAcpAdapter(options = {}) {
89
97
  if (closing || ['closed', 'failed', 'exited'].includes(status) || method !== 'session/update' || params?.sessionId !== sessionId) return;
90
98
  const update = params.update;
91
99
  if (!update || typeof update !== 'object') return;
100
+ const reported = acpUsage(update);
101
+ if (reported) { metrics = reported; emit({ type: 'usage', metrics, turnId: active?.turnId || null }); }
102
+ if (update.sessionUpdate === 'available_commands_update' && Array.isArray(update.availableCommands)) {
103
+ runtimeMetadata = { ...runtimeMetadata, commands: update.availableCommands.filter(command => typeof command?.name === 'string').map(command => ({ name: command.name, description: command.description })) };
104
+ emit({ type: 'metadata', runtimeMetadata: { ...runtimeMetadata } });
105
+ }
92
106
  rememberToolCall(update);
93
107
  if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text' && typeof update.content.text === 'string') {
94
108
  if (active) active.text += update.content.text;
@@ -135,6 +149,8 @@ export function createDshAcpAdapter(options = {}) {
135
149
  }
136
150
 
137
151
  function onExit(info) {
152
+ runtimeExited = true;
153
+ cwdBinding.release();
138
154
  if (closing || runtimeDisposed || status === 'closed') return;
139
155
  const turn = active;
140
156
  interactions.expire();
@@ -176,6 +192,7 @@ export function createDshAcpAdapter(options = {}) {
176
192
  openingIdentity = input.sessionId || null;
177
193
  opening = (async () => {
178
194
  try {
195
+ cwdBinding.assertUnchanged();
179
196
  const config = {
180
197
  binary: options.binary || 'dsh', args: ['--profile', 'acp'],
181
198
  cwd, env: options.env, timeoutMs: options.timeoutMs,
@@ -193,6 +210,7 @@ export function createDshAcpAdapter(options = {}) {
193
210
  capabilities.resume = extensions.resume !== undefined && extensions.resume !== null && extensions.resume !== false;
194
211
  capabilities.close = extensions.close !== undefined && extensions.close !== null && extensions.close !== false;
195
212
  if (input.sessionId && !capabilities.resume) throw failure('NATIVE_UNSUPPORTED', 'This ACP runtime does not advertise session resume');
213
+ cwdBinding.assertUnchanged();
196
214
  const result = await rpc.request(input.sessionId ? 'session/resume' : 'session/new', {
197
215
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
198
216
  cwd, mcpServers: Object.entries(options.mcpServers || {}).map(([name, config]) => ({ name, command: config.command, args: config.args,
@@ -203,13 +221,18 @@ export function createDshAcpAdapter(options = {}) {
203
221
  throw failure('NATIVE_PROTOCOL_ERROR', 'ACP returned an invalid session identity');
204
222
  }
205
223
  sessionId = resolvedId;
224
+ const reportedModel = result?.configOptions?.find(option => option?.id === 'model')?.currentValue || result?.models?.currentModelId;
225
+ runtimeMetadata = typeof reportedModel === 'string' ? { model: reportedModel } : {};
206
226
  if (input.model !== undefined) {
207
227
  const model = result?.configOptions?.find((option) => option?.id === 'model');
208
228
  if (!model || !advertisesValue(model.options, input.model)) throw failure('NATIVE_UNSUPPORTED_MODEL', 'Model must match an advertised ACP model option value');
209
- await rpc.request('session/set_config_option', { sessionId, configId: 'model', value: input.model });
229
+ const configured = await rpc.request('session/set_config_option', { sessionId, configId: 'model', value: input.model });
230
+ const confirmed = configured?.configOptions?.find(option => option?.id === 'model')?.currentValue;
231
+ runtimeMetadata = typeof confirmed === 'string' ? { model: confirmed } : {};
210
232
  }
211
233
  if (closing || status === 'exited') throw failure('NATIVE_ADAPTER_CLOSED', 'The dsh ACP runtime closed during session initialization');
212
234
  status = 'idle';
235
+ emit({ type: 'metadata', runtimeMetadata: { ...runtimeMetadata } });
213
236
  emit({ type: 'opened', resumed: Boolean(input.sessionId), status });
214
237
  return snapshot();
215
238
  } catch (error) {
@@ -239,6 +262,7 @@ export function createDshAcpAdapter(options = {}) {
239
262
  }
240
263
  if (input.expectedTurnId && input.expectedTurnId !== active?.turnId) throw failure('NATIVE_STALE_TURN', 'The expected ACP submission is no longer active');
241
264
  if (active) throw failure('NATIVE_BUSY', 'ACP permits one in-flight prompt per session and does not support steering');
265
+ cwdBinding.assertUnchanged();
242
266
  clearToolContext();
243
267
  const turn = { turnId: randomUUID(), submissionId, text: '' };
244
268
  active = turn;
@@ -252,11 +276,12 @@ export function createDshAcpAdapter(options = {}) {
252
276
  // only the real RPC response constitutes a completed submission.
253
277
  void Promise.resolve().then(() => {
254
278
  if (closing) throw failure('NATIVE_ADAPTER_CLOSED', 'The adapter closed before prompt submission');
279
+ cwdBinding.assertUnchanged();
255
280
  return rpc.request('session/prompt', {
256
281
  sessionId, prompt: [{ type: 'text', text: input.text }]
257
282
  }, { timeoutMs: 0 });
258
283
  }).then((result) => {
259
- if (active !== turn) return;
284
+ if (active !== turn || closing) return;
260
285
  if (!['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(result?.stopReason)) {
261
286
  throw failure('NATIVE_PROTOCOL_ERROR', 'ACP prompt returned an invalid stop reason');
262
287
  }
@@ -267,7 +292,7 @@ export function createDshAcpAdapter(options = {}) {
267
292
  emit({ type: 'completed', status: result.stopReason === 'cancelled' ? 'interrupted' : 'completed',
268
293
  sessionId, turnId: turn.turnId, submissionId, text: turn.text, stopReason: result.stopReason });
269
294
  }).catch((error) => {
270
- if (active !== turn) return;
295
+ if (active !== turn || closing) return;
271
296
  interactions.expire(turn.turnId);
272
297
  clearToolContext();
273
298
  active = null;
@@ -300,30 +325,48 @@ export function createDshAcpAdapter(options = {}) {
300
325
  }
301
326
 
302
327
  function close() {
303
- if (closing) return closing;
304
- closing = (async () => {
305
- status = 'closing';
306
- interactions.expire();
307
- clearToolContext();
308
- const errors = [];
309
- if (opening) { try { await opening; } catch {} }
310
- if (rpc && !runtimeDisposed && sessionId && capabilities.close) {
311
- try { await rpc.request('session/close', { sessionId }); } catch (error) { errors.push(error); }
312
- }
313
- try { await disposeRuntime(); } catch (error) { errors.push(error); }
314
- active = null;
315
- status = 'closed';
316
- emit({ type: 'closed', status });
317
- if (errors.length) throw new AggregateError(errors, 'ACP session or owned runtime cleanup failed');
318
- return snapshot();
328
+ if (closePromise) return closePromise;
329
+ if (runtimeDisposed && status === 'closed') return Promise.resolve(snapshot());
330
+ // Once closure is requested, failed cleanup must never reopen this worker.
331
+ // Only the in-flight cleanup promise is retryable.
332
+ closing = true;
333
+ const operation = (async () => {
334
+ try {
335
+ status = 'closing';
336
+ interactions.expire();
337
+ clearToolContext();
338
+ const errors = [];
339
+ if (opening) { try { await opening; } catch {} }
340
+ if (rpc && !runtimeDisposed && !runtimeExited && !runtimeDisposalStarted && sessionId && capabilities.close) {
341
+ try { await rpc.request('session/close', { sessionId }); } catch (error) { errors.push(error); }
342
+ }
343
+ try { await disposeRuntime(); } catch (error) { errors.push(error); }
344
+ if (errors.length) lastError = { ...describe(errors.at(-1)), ...(!runtimeDisposed ? { uncertain: true } : {}) };
345
+ status = runtimeDisposed ? 'closed' : 'failed';
346
+ if (runtimeDisposed) {
347
+ active = null;
348
+ emit({ type: 'closed', status });
349
+ }
350
+ if (errors.length) throw new AggregateError(errors, 'ACP session or owned runtime cleanup failed');
351
+ return snapshot();
352
+ } finally { cwdBinding.release(); }
319
353
  })();
320
- return closing;
354
+ closePromise = operation;
355
+ void operation.catch(() => { if (closePromise === operation) closePromise = null; });
356
+ return operation;
321
357
  }
322
358
 
323
359
  async function disposeRuntime() {
324
- if (runtimeDisposed) return;
325
- runtimeDisposed = true;
326
- await rpc?.close();
360
+ if (runtimeDisposed || runtimeExited) { runtimeDisposed = true; return; }
361
+ runtimeDisposalStarted = true;
362
+ try {
363
+ await rpc?.close();
364
+ runtimeDisposed = true;
365
+ } finally {
366
+ // The transport may report its own cleanup failure after observing exit.
367
+ // Preserve that error while retaining the independent exit receipt.
368
+ if (runtimeExited) runtimeDisposed = true;
369
+ }
327
370
  }
328
371
 
329
372
  return { capabilities, open, send, interrupt, close, snapshot, respond: interactions.respond };
@@ -1,4 +1,5 @@
1
1
  import { CliError } from '../../shared/errors.mjs';
2
+ import { HOOK_ROOT_IDENTITY_ENV, hookRootIdentityValue } from '../../core/sessions/hook-root-identity.mjs';
2
3
 
3
4
  export const NATIVE_PROVIDERS = Object.freeze(['codex', 'claude', 'dsh']);
4
5
 
@@ -15,5 +16,7 @@ export function nativeWorkerEnv(env, ctx, peer, owner) {
15
16
  !['CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CLAUDE_SESSION_ID', 'CLAUDECODE'].includes(key)));
16
17
  // Explicit, worker-specific coordination identity; provider credentials and
17
18
  // the real HOME are preserved. No global configuration is rewritten.
18
- return { ...clean, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, HCC_PEER: peer, ...(owner ? { HCC_NATIVE_OWNER: owner } : {}) };
19
+ return { ...clean, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, HCC_PEER: peer,
20
+ [HOOK_ROOT_IDENTITY_ENV]: hookRootIdentityValue(ctx.root, ctx.initialRootIdentity || ctx.rootIdentity),
21
+ ...(owner ? { HCC_NATIVE_OWNER: owner } : {}) };
19
22
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { redactMcpUrlInteraction } from '../mcp-url-elicitation.mjs';
2
3
 
3
4
  // Only the owning runtime can answer. Answers, including secrets, are never
4
5
  // placed in snapshots, events or persistent delivery receipts.
@@ -33,7 +34,7 @@ export function createNativeInteractions({ executorId = randomUUID(), isActive,
33
34
  signal.addEventListener('abort', aborted, { once: true });
34
35
  entry.cleanup = () => signal.removeEventListener('abort', aborted);
35
36
  }
36
- onChange?.({ type: 'approval', ...snapshot().find(value => value.requestId === id) });
37
+ onChange?.(redactMcpUrlInteraction({ type: 'approval', ...snapshot().find(value => value.requestId === id) }));
37
38
  });
38
39
  return promise;
39
40
  }
@@ -1,5 +1,28 @@
1
- import { spawn } from 'node:child_process';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { CliError } from '../../shared/errors.mjs';
4
+ import { spawnPinnedCwdProcess } from '../../process/pinned-cwd.mjs';
5
+
6
+ function assertExecutableAvailable(binary, cwd, env) {
7
+ const searchPath = env === undefined ? process.env.PATH : env.PATH;
8
+ const candidates = binary.includes('/')
9
+ ? [path.resolve(cwd, binary)]
10
+ : String(searchPath === undefined ? '/usr/bin:/bin' : searchPath)
11
+ .split(path.delimiter).map(entry => path.resolve(cwd, entry || '.', binary));
12
+ let inaccessible = false;
13
+ for (const candidate of candidates) {
14
+ try {
15
+ fs.accessSync(candidate, fs.constants.X_OK);
16
+ if (fs.statSync(candidate).isFile()) return;
17
+ } catch (error) {
18
+ if (error?.code === 'EACCES') inaccessible = true;
19
+ }
20
+ }
21
+ const code = inaccessible ? 'EACCES' : 'ENOENT';
22
+ throw Object.assign(new Error('Cannot start ' + binary + ': ' + code), {
23
+ code, path: binary, syscall: 'spawn'
24
+ });
25
+ }
3
26
 
4
27
  // Each transport owns the child it starts. It never connects to a desktop
5
28
  // application's private server or signals a process supplied by the caller.
@@ -18,7 +41,10 @@ export class JsonRpcProcess {
18
41
  async start() {
19
42
  if (this.child || this.closed) throw new CliError('NATIVE_TRANSPORT_STATE', 'Transport cannot be started twice');
20
43
  const { binary, args = [], cwd, env } = this.options;
21
- this.child = spawn(binary, args, { cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
44
+ assertExecutableAvailable(binary, cwd || process.cwd(), env);
45
+ this.child = spawnPinnedCwdProcess(binary, args, {
46
+ cwd: cwd || process.cwd(), env, shell: false, stdio: ['pipe', 'pipe', 'pipe']
47
+ });
22
48
  this.child.stdout.setEncoding('utf8');
23
49
  this.child.stdout.on('data', (chunk) => this.read(chunk));
24
50
  // Drain stderr without putting credentials or unbounded provider logs into
@@ -86,8 +112,8 @@ export class JsonRpcProcess {
86
112
  const error = new CliError('NATIVE_PROTOCOL_ERROR', message, { uncertain: true });
87
113
  this.fail(error);
88
114
  this.reportError(error);
89
- // Retain a rejected closePromise for explicit callers without making an
90
- // asynchronous protocol failure produce an unhandled rejection.
115
+ // Report failed cleanup without an unhandled rejection. Explicit callers
116
+ // may retry termination; the original attempt keeps its failure receipt.
91
117
  void this.close().catch((closeError) => {
92
118
  this.lastCloseError = closeError;
93
119
  this.reportError(closeError);
@@ -180,6 +206,12 @@ export class JsonRpcProcess {
180
206
  try { child.stdin.end(); } catch {}
181
207
  signal('SIGTERM');
182
208
  });
183
- return this.closePromise;
209
+ const closing = this.closePromise;
210
+ void closing.catch(() => {
211
+ // Share a pending attempt, but do not pin a transient signal/exit failure
212
+ // forever. A retry must still confirm this owned child's actual exit.
213
+ if (this.closePromise === closing) this.closePromise = null;
214
+ });
215
+ return closing;
184
216
  }
185
217
  }
@@ -0,0 +1,30 @@
1
+ // Preserve only counters actually reported by the upstream protocol. No token
2
+ // estimates, local duration substitutes, or cumulative-usage/context inference.
3
+ function fields(source, mapping) {
4
+ const result = {};
5
+ for (const [key, field] of Object.entries(mapping)) {
6
+ const value = source?.[field];
7
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) result[key] = value;
8
+ }
9
+ return result;
10
+ }
11
+
12
+ export function codexUsage(params, observedAt = Date.now()) {
13
+ const usage = params?.tokenUsage;
14
+ if (!usage || typeof usage !== 'object') return null;
15
+ const counters = fields(usage.total, { inputTokens: 'inputTokens', outputTokens: 'outputTokens', cachedInputTokens: 'cachedInputTokens', totalTokens: 'totalTokens' });
16
+ Object.assign(counters, fields(usage, { contextWindow: 'modelContextWindow' }));
17
+ return Object.keys(counters).length ? { source: 'codex thread/tokenUsage/updated', scope: 'session', ...(params.turnId ? { turnId: params.turnId } : {}), observedAt, ...counters } : null;
18
+ }
19
+
20
+ export function claudeUsage(message, turnId, observedAt = Date.now()) {
21
+ const counters = { ...fields(message?.usage, { inputTokens: 'input_tokens', outputTokens: 'output_tokens', cachedInputTokens: 'cache_read_input_tokens', totalTokens: 'total_tokens' }),
22
+ ...fields(message, { durationMs: 'duration_ms' }) };
23
+ return Object.keys(counters).length ? { source: 'claude SDK result', scope: 'turn', ...(turnId ? { turnId } : {}), observedAt, ...counters } : null;
24
+ }
25
+
26
+ export function acpUsage(update, observedAt = Date.now()) {
27
+ if (update?.sessionUpdate !== 'usage_update') return null;
28
+ const counters = fields(update, { contextTokens: 'used', contextWindow: 'size' });
29
+ return Object.keys(counters).length ? { source: 'ACP session/update usage_update', scope: 'session', observedAt, ...counters } : null;
30
+ }
@@ -97,6 +97,10 @@ function macTokenFormat(token) {
97
97
  if (legacy && BigInt(legacy[1]) > 0n && Number(legacy[2]) >= 0 && Number(legacy[2]) <= 999999) return 'legacy-wall-clock';
98
98
  const current = /^darwin:([^:]+):(.+)$/.exec(token);
99
99
  if (current && MAC_BOOT_UUID.test(current[1]) && new RegExp(`^${start}$`).test(current[2])) return 'boot-session';
100
+ // Earlier workbench candidates used a different prefix for the same OS
101
+ // evidence. A prefix change alone must not prove that their owner exited.
102
+ const preview = /^mac:([^:]+):(.+)$/.exec(token);
103
+ if (preview && MAC_BOOT_UUID.test(preview[1]) && new RegExp(`^${start}$`).test(preview[2])) return 'preview-boot-session';
100
104
  return null;
101
105
  }
102
106