@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,27 +1,273 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { isDeepStrictEqual } from 'node:util';
4
5
  import { CliError } from '../shared/errors.mjs';
5
6
  import { redactSecrets } from '../shared/redact.mjs';
6
7
  import { tx } from '../db/schema.mjs';
7
8
  import { createEventHelpers } from '../db/events.mjs';
8
9
  import { nativeRequest } from '../runtime/native/client.mjs';
10
+ import { ensureNativeRuntime } from '../runtime/native/launcher.mjs';
11
+ import { readNativeHistory, readNativeWorkerHistory } from '../runtime/native/history.mjs';
12
+ import { parseNativeCwdIdentity } from '../runtime/native/store.mjs';
13
+ import { captureSelectedCwdSnapshot, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
14
+ import { createAgentDefaults } from './agent-defaults.mjs';
9
15
 
10
16
  const PROVIDERS = new Set(['codex', 'claude', 'dsh']);
11
17
  const CLOSED = new Set(['closed', 'exited', 'stopped']);
12
18
  const HISTORY_LIMIT = 200;
13
19
 
14
20
  // A Web view of a worker already owned by the independent native service.
15
- // This module never starts a service, provider process, or foreign TUI session.
21
+ // New workers are delegated to that same service; Web never owns their process.
16
22
  export function createNativeSessions({ sessions, sessionKey, connectWebProject,
17
23
  broadcast = () => {}, closeSessionClients = () => {},
18
24
  now = () => Math.floor(Date.now() / 1000), nativeApi = nativeRequest,
25
+ ensureRuntime = ensureNativeRuntime,
19
26
  addEvent = createEventHelpers({ now }).addEvent } = {}) {
20
27
  const projects = new Map();
21
28
  const scans = new Map();
29
+ const creating = new Set();
30
+ const { resolveNativeDefaults } = createAgentDefaults({ connectWebProject });
22
31
  let stopped = false;
23
32
 
24
33
  function projectKey(ctx) { return `${ctx.root}\0${path.resolve(ctx.dbPath)}`; }
34
+
35
+ function workerCwd(ctx, value) {
36
+ if (value !== undefined && (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value))) {
37
+ throw new CliError('BAD_REQUEST', 'Native worker cwd must be a nonempty directory path');
38
+ }
39
+ let root, cwd;
40
+ try {
41
+ root = fs.realpathSync(ctx.root);
42
+ cwd = fs.realpathSync(path.resolve(root, value === undefined ? '.' : value));
43
+ if (!fs.statSync(cwd).isDirectory()) throw new Error('not a directory');
44
+ } catch { throw new CliError('BAD_REQUEST', 'Native worker cwd must be an existing directory'); }
45
+ const relative = path.relative(root, cwd);
46
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
47
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Native worker cwd must be inside the selected project; register an external directory as a separate project first');
48
+ }
49
+ return cwd;
50
+ }
51
+
52
+ function hasSavedPeer(ctx, peer) {
53
+ const db = connectWebProject(ctx);
54
+ try {
55
+ return Boolean(db.prepare('SELECT id FROM peers WHERE id=?').get(peer) ||
56
+ db.prepare('SELECT peer FROM peer_bindings WHERE peer=?').get(peer));
57
+ } finally { db.close(); }
58
+ }
59
+
60
+ function assertNewPeer(ctx, peer) {
61
+ if (sessions.has(sessionKey(ctx, peer))) throw new CliError('NATIVE_PEER_IN_USE', 'This name already identifies a session; choose a new worker name');
62
+ if (hasSavedPeer(ctx, peer)) throw new CliError('NATIVE_PEER_IN_USE', 'This peer already has an owner or saved session; choose a new worker name');
63
+ }
64
+
65
+ async function historyRuntime(ctx) {
66
+ try {
67
+ const status = await nativeApi(ctx, 'GET', '/status', null, { timeoutMs: 2000 });
68
+ assertProject(ctx, status);
69
+ if (!Array.isArray(status.workers)) throw new CliError('NATIVE_RESPONSE_INVALID', 'Native worker list is invalid');
70
+ return status;
71
+ } catch (error) {
72
+ if (['NATIVE_RUNTIME_OFFLINE', 'NATIVE_CLIENT_TIMEOUT'].includes(error.code)) return null;
73
+ throw error;
74
+ }
75
+ }
76
+ function historyWorker(ctx, row, runtime, lookupDb = null) {
77
+ const db = lookupDb || connectWebProject(ctx);
78
+ let binding, peer;
79
+ try {
80
+ binding = db.prepare('SELECT * FROM peer_bindings WHERE peer=?').get(row.peer);
81
+ peer = db.prepare('SELECT capabilities FROM peers WHERE id=?').get(row.peer);
82
+ } finally { if (!lookupDb) db.close(); }
83
+ let capabilities = {};
84
+ try { capabilities = JSON.parse(peer?.capabilities || '{}'); } catch {}
85
+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) capabilities = {};
86
+ const live = runtime?.workers.find(worker => worker.peer === row.peer);
87
+ const owned = live?.owned === true;
88
+ const identityMatches = binding?.transport === 'native' && binding.provider === row.provider &&
89
+ binding.runtime_session_id === row.peer && binding.provider_session_id === row.session_id && Boolean(binding.runtime_target);
90
+ let resumeReason = null, resumeDetail = null;
91
+ if (owned) { resumeReason = 'active'; resumeDetail = 'Open the existing worker. Explicitly close its executor before restoring it.'; }
92
+ else if (row.status !== 'closed') { resumeReason = 'not_closed'; resumeDetail = 'The previous executor is not confirmed closed; inspect native status and finish its explicit close first.'; }
93
+ else if (!row.session_id) { resumeReason = 'no_session'; resumeDetail = 'No provider session identity was recorded for this worker.'; }
94
+ else if (!identityMatches) { resumeReason = 'owner_changed'; resumeDetail = 'The saved worker binding changed; refresh or inspect its current owner.'; }
95
+ else if (!PROVIDERS.has(row.provider) || capabilities.resume === false) { resumeReason = 'provider_unsupported'; resumeDetail = 'This worker did not advertise provider session restore support.'; }
96
+ else if (row.provider === 'dsh' && capabilities.resume !== true) { resumeReason = 'capability_unknown'; resumeDetail = 'No dsh ACP resume capability was recorded for this worker.'; }
97
+ else {
98
+ const savedCwd = parseNativeCwdIdentity(row.cwd_identity, row.cwd);
99
+ if (!savedCwd) {
100
+ resumeReason = 'history_unverified';
101
+ resumeDetail = 'This legacy worker has no trusted working-directory identity and cannot be restored automatically.';
102
+ } else {
103
+ try {
104
+ workerCwd(ctx, row.cwd);
105
+ if (!sameSelectedCwdIdentity(savedCwd, captureSelectedCwdSnapshot(row.cwd))) {
106
+ resumeReason = 'cwd_changed';
107
+ resumeDetail = 'The saved working directory has changed identity; choose a new worker.';
108
+ }
109
+ } catch {
110
+ resumeReason = 'cwd_unavailable';
111
+ resumeDetail = 'The saved working directory is unavailable or outside the selected project.';
112
+ }
113
+ }
114
+ }
115
+ return { peer: row.peer, provider: row.provider, sessionId: row.session_id || null, cwd: row.cwd,
116
+ status: live?.status || row.status, updatedAt: row.updated_at, owned, owner: binding?.runtime_target || null,
117
+ capabilities: { resume: capabilities.resume === true || (row.provider !== 'dsh' && capabilities.resume !== false) },
118
+ resumable: !resumeReason, resumeReason, resumeDetail };
119
+ }
120
+ const retention = { scope: 'retained-project-events', maxEvents: 2001, complete: false };
121
+ async function listNativeHistory(ctx) {
122
+ const saved = readNativeHistory(ctx), runtime = await historyRuntime(ctx);
123
+ const db = saved.workers.length ? connectWebProject(ctx) : null;
124
+ try {
125
+ return { workers: saved.workers.map(row => historyWorker(ctx, row, runtime, db)), truncated: saved.truncated,
126
+ runtimeAvailable: Boolean(runtime), retention };
127
+ } finally { db?.close(); }
128
+ }
129
+ async function nativeWorkerHistory(ctx, peer, options = {}) {
130
+ const saved = readNativeWorkerHistory(ctx, peer, options), runtime = await historyRuntime(ctx);
131
+ return { ...saved, worker: historyWorker(ctx, saved.worker, runtime), runtimeAvailable: Boolean(runtime), retention };
132
+ }
133
+ async function resumeNativeSession(ctx, peer, input) {
134
+ if (!input || typeof input !== 'object' || Array.isArray(input) ||
135
+ Object.keys(input).some(key => !['owner', 'sessionId', 'confirmed'].includes(key)) || input.confirmed !== true ||
136
+ typeof input.owner !== 'string' || !input.owner || input.owner.length > 512 ||
137
+ typeof input.sessionId !== 'string' || !input.sessionId || input.sessionId.length > 512) {
138
+ throw new CliError('BAD_REQUEST', 'Restore requires confirmed:true and the saved owner and sessionId');
139
+ }
140
+ const key = sessionKey(ctx, peer);
141
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web is stopping');
142
+ if (creating.has(key)) throw new CliError('NATIVE_WORKER_EXISTS', 'This worker is already being opened', { peer });
143
+ creating.add(key);
144
+ let receipt;
145
+ try {
146
+ const saved = readNativeWorkerHistory(ctx, peer).worker;
147
+ let entry = historyWorker(ctx, saved, await historyRuntime(ctx));
148
+ if (entry.owner !== input.owner || entry.sessionId !== input.sessionId) throw new CliError('NATIVE_OWNER_CHANGED', 'Saved worker identity changed; refresh history');
149
+ if (!entry.resumable) throw new CliError('NATIVE_RESUME_NOT_READY', entry.resumeDetail);
150
+ const existing = sessions.get(key);
151
+ if (existing && existing.type !== 'native') throw new CliError('NATIVE_PEER_IN_USE', 'Another session owns this peer');
152
+ await ensureRuntime(ctx);
153
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped before worker restore');
154
+ entry = historyWorker(ctx, readNativeWorkerHistory(ctx, peer).worker, await historyRuntime(ctx));
155
+ if (entry.owner !== input.owner || entry.sessionId !== input.sessionId) throw new CliError('NATIVE_OWNER_CHANGED', 'Saved worker identity changed; refresh history');
156
+ if (!entry.resumable) throw new CliError('NATIVE_RESUME_NOT_READY', entry.resumeDetail);
157
+ try {
158
+ receipt = await nativeApi(ctx, 'POST', '/workers', { peer, provider: saved.provider, cwd: workerCwd(ctx, saved.cwd),
159
+ resume: 'last', resumeFence: { owner: input.owner, sessionId: input.sessionId } });
160
+ } catch (error) {
161
+ let ownerChanged = true;
162
+ try { ownerChanged = historyWorker(ctx, saved, null).owner !== input.owner; } catch {}
163
+ if (error.extra?.uncertain || ownerChanged) throw new CliError('NATIVE_RESUME_UNCONFIRMED',
164
+ 'Restore of ' + peer + ' needs inspection: ' + (error.message || 'Native request failed') +
165
+ '. Refresh this worker and inspect native status before another restore; do not create a replacement.',
166
+ { peer, provider: saved.provider, uncertain: true, cause: error.code || 'UNKNOWN' });
167
+ throw error;
168
+ }
169
+ try {
170
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped after worker restore');
171
+ if (receipt?.peer !== peer || receipt.provider !== saved.provider || typeof receipt.executorId !== 'string' ||
172
+ !receipt.executorId || receipt.executorId === input.owner) throw new CliError('NATIVE_RESPONSE_INVALID', 'Native restore receipt has no new matching executor');
173
+ let value = await nativeApi(ctx, 'GET', '/workers/' + encodeURIComponent(peer) + '/state');
174
+ const checked = await validateObservedState(ctx, peer, value);
175
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped while validating the restored worker');
176
+ value = checked.value;
177
+ if (value.owner !== receipt.executorId || checked.binding.provider_session_id !== input.sessionId) throw new CliError('NATIVE_OWNER_CHANGED', 'Restored worker identity changed');
178
+ const currentView = sessions.get(key);
179
+ if (currentView && currentView.type !== 'native') throw new CliError('NATIVE_PEER_IN_USE', 'Another session owns the worker view');
180
+ if (currentView && !currentView.nativeRetired && sameIdentity(currentView, value)) { applyState(currentView, checked); return currentView; }
181
+ if (currentView && !currentView.nativeRetired) disconnect(currentView, new CliError('NATIVE_OWNER_CHANGED', 'Worker restored into a new executor'), true);
182
+ projects.set(projectKey(ctx), ctx);
183
+ return makeSession(ctx, checked);
184
+ } catch (error) {
185
+ throw new CliError('NATIVE_WORKER_DISCOVERY_FAILED', 'Worker ' + peer + ' was restored, but its Web view could not open. Refresh the Agent list; do not create a replacement.',
186
+ { peer, provider: saved.provider, created: true, executorId: receipt?.executorId, cause: error.code || 'UNKNOWN' });
187
+ }
188
+ } finally { creating.delete(key); }
189
+ }
190
+
191
+ async function startNativeSession(input) {
192
+ if (!input || typeof input !== 'object' || Array.isArray(input) ||
193
+ Object.keys(input).some(key => !['projectCtx', 'transport', 'kind', 'id', 'cwd', 'model'].includes(key)) ||
194
+ input.transport !== 'native' || (input.kind !== undefined && !PROVIDERS.has(input.kind))) {
195
+ throw new CliError('BAD_REQUEST', 'Native creation accepts only transport, kind, id, cwd and model; use provider codex, claude or dsh');
196
+ }
197
+ const ctx = input.projectCtx;
198
+ // Resolve before asynchronous daemon startup; explicit null clears a saved model.
199
+ input = resolveNativeDefaults(ctx, input);
200
+ const peer = input.id === undefined ? `native-${input.kind}-${randomUUID()}` : input.id;
201
+ if (typeof peer !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(peer) || peer === 'all') {
202
+ throw new CliError('BAD_REQUEST', 'Native worker name must contain 1 to 128 letters, digits, dots, underscores or hyphens, start with a letter or digit, and cannot be all');
203
+ }
204
+ if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim() ||
205
+ input.model.length > 256 || /[\x00-\x1f\x7f]/.test(input.model))) {
206
+ throw new CliError('BAD_REQUEST', 'Native model must be a nonempty name of at most 256 characters');
207
+ }
208
+ const cwd = workerCwd(ctx, input.cwd);
209
+ const key = sessionKey(ctx, peer);
210
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web is stopping');
211
+ if (creating.has(key)) throw new CliError('NATIVE_WORKER_EXISTS', 'This worker is already being created', { peer });
212
+ assertNewPeer(ctx, peer);
213
+ creating.add(key);
214
+ try {
215
+ await ensureRuntime(ctx);
216
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped before worker creation');
217
+ assertNewPeer(ctx, peer);
218
+ workerCwd(ctx, cwd);
219
+ let receipt;
220
+ try {
221
+ // Exactly one create attempt. A lost response is not safe to replay.
222
+ receipt = await nativeApi(ctx, 'POST', '/workers', { peer, provider: input.kind, cwd,
223
+ ...(input.model === undefined ? {} : { model: input.model }) });
224
+ } catch (error) {
225
+ if (error.extra?.uncertain) throw new CliError('NATIVE_CREATE_UNCONFIRMED',
226
+ `Creation of ${peer} is unconfirmed. Refresh the Agent list or inspect native status before creating another worker`,
227
+ { peer, provider: input.kind, uncertain: true, cause: error.code || 'UNKNOWN' });
228
+ // Initialization can fail after the daemon reserves this peer. Keep
229
+ // its ownership records and original failure, but make retry guidance
230
+ // actionable even when the caller let the server generate the name.
231
+ let retained = null;
232
+ try { retained = hasSavedPeer(ctx, peer); } catch { /* Preserve the provider error if the read fails. */ }
233
+ const guidance = retained === false ? '' :
234
+ ' Peer ' + peer + (retained ? ' is still' : ' may still be') +
235
+ ' reserved in the selected project. Run hcc native status there to inspect it; after correcting the reported error, choose a new Agent name to retry.';
236
+ throw new CliError(error.code || 'NATIVE_REQUEST_FAILED', (error.message || 'Native creation failed') + guidance,
237
+ { ...error.extra, peer, provider: input.kind });
238
+ }
239
+ try {
240
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped after worker creation');
241
+ if (receipt?.peer !== peer || receipt.provider !== input.kind || typeof receipt.executorId !== 'string' || !receipt.executorId) {
242
+ throw new CliError('NATIVE_RESPONSE_INVALID', 'Native create receipt has no matching worker identity');
243
+ }
244
+ let value = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(peer)}/state`);
245
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped while opening the worker view');
246
+ const checked = await validateObservedState(ctx, peer, value);
247
+ if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped while validating the worker view');
248
+ value = checked.value;
249
+ if (value.owner !== receipt.executorId || (receipt.sessionId && value.snapshot.sessionId !== receipt.sessionId)) {
250
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Worker identity changed after creation');
251
+ }
252
+ const existing = sessions.get(key);
253
+ if (existing) {
254
+ if (existing.type !== 'native' || existing.nativeRetired || !sameIdentity(existing, value)) {
255
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Another session owns the worker view');
256
+ }
257
+ applyState(existing, checked);
258
+ return existing;
259
+ }
260
+ projects.set(projectKey(ctx), ctx);
261
+ return makeSession(ctx, checked);
262
+ } catch (error) {
263
+ // The daemon keeps the created worker even if Web closes or its first
264
+ // read fails. Its stable peer lets the next view recover that worker.
265
+ throw new CliError('NATIVE_WORKER_DISCOVERY_FAILED',
266
+ `Worker ${peer} was created, but its Web view could not open. Refresh the Agent list or inspect native status; do not create a replacement`,
267
+ { peer, provider: input.kind, created: true, cause: error.code || 'UNKNOWN' });
268
+ }
269
+ } finally { creating.delete(key); }
270
+ }
25
271
  function projectSessions(ctx) {
26
272
  return [...sessions.values()].filter((session) => session.type === 'native' &&
27
273
  projectKey(session.ctx) === projectKey(ctx) && !session.nativeRetired);
@@ -36,7 +282,8 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
36
282
  // still run on every read, including reads that need no new UI frame.
37
283
  const changed = !isDeepStrictEqual(session.nativeState, state);
38
284
  session.nativeState = state;
39
- if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
285
+ // broadcast is synchronous and serializes/retains changed fields itself.
286
+ if (changed) broadcast(session, { type: 'native_state', state });
40
287
  }
41
288
  function disconnect(session, error, retire = false) {
42
289
  session.status = 'disconnected';
@@ -54,7 +301,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
54
301
  throw new CliError('NATIVE_SCOPE_CHANGED', 'Native runtime does not match this project');
55
302
  }
56
303
  }
57
- function validateState(ctx, peer, value) {
304
+ function validateState(ctx, peer, value, { allowPendingInit = false } = {}) {
58
305
  assertProject(ctx, value);
59
306
  if (value.peer !== peer || !PROVIDERS.has(value.provider) ||
60
307
  typeof value.owner !== 'string' || !value.owner || value.owner.length > 512 ||
@@ -74,14 +321,46 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
74
321
  // with no confirmed session ID; its init event must confirm that ID.
75
322
  const lazyClaudeResume = value.provider === 'claude' && value.snapshot.status === 'ready' &&
76
323
  value.snapshot.sessionId == null;
324
+ // Observation may have captured initialization before the shared binding
325
+ // acquired its session ID. The caller must confirm that ID with a fresh,
326
+ // fenced read before applying this otherwise valid same-owner snapshot.
327
+ const pendingInit = allowPendingInit && value.snapshot.sessionId == null &&
328
+ (value.provider !== 'claude' || ['ready', 'queued', 'running'].includes(value.snapshot.status));
77
329
  if (!binding || binding.transport !== 'native' || binding.provider !== value.provider ||
78
330
  binding.runtime_target !== value.owner ||
79
- ((binding.provider_session_id || null) !== (value.snapshot.sessionId || null) && !lazyClaudeResume)) {
331
+ ((binding.provider_session_id || null) !== (value.snapshot.sessionId || null) && !lazyClaudeResume && !pendingInit)) {
80
332
  throw new CliError('NATIVE_OWNER_CHANGED', 'Native worker binding changed; refresh the current worker');
81
333
  }
82
334
  return { value, binding: { ...binding } };
83
335
  } finally { db.close(); }
84
336
  }
337
+ async function validateObservedState(ctx, peer, value) {
338
+ const checked = validateState(ctx, peer, value, { allowPendingInit: true });
339
+ const existing = sessions.get(sessionKey(ctx, peer));
340
+ const confirmedId = checked.binding.provider_session_id;
341
+ const sameExecutor = existing?.nativeIdentity?.generation === value.generation &&
342
+ existing.nativeIdentity.owner === value.owner && existing.nativeIdentity.provider === value.provider;
343
+ // A restored executor keeps the saved binding before its first SDK init,
344
+ // including while that first input is queued/running. Only this executor's
345
+ // view can establish that pending restore; the previous owner's UUID cannot.
346
+ const pendingResume = sameExecutor && !existing.nativeIdentity.sessionId &&
347
+ existing.binding?.provider_session_id === confirmedId;
348
+ if (value.snapshot.sessionId != null || !confirmedId) return checked;
349
+ if (value.provider === 'claude' &&
350
+ (pendingResume || (value.snapshot.status === 'ready' && (!sameExecutor || !existing.nativeIdentity.sessionId)))) return checked;
351
+ // The HTTP snapshot can precede SDK init while the shared binding already
352
+ // contains its confirmed UUID. Never apply that older null snapshot: read
353
+ // once more under the same service/worker identity, then validate strictly.
354
+ const params = new URLSearchParams({ after: String(sameExecutor ? existing.nativeState?.eventCursor || 0 : 0),
355
+ generation: value.generation, owner: value.owner });
356
+ const refreshed = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(peer)}/state?${params}`, null, { timeoutMs: 2000 });
357
+ const current = validateState(ctx, peer, refreshed);
358
+ if (refreshed.generation !== value.generation || refreshed.owner !== value.owner ||
359
+ refreshed.provider !== value.provider || refreshed.snapshot.sessionId !== confirmedId) {
360
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Native worker identity changed while confirming provider initialization');
361
+ }
362
+ return current;
363
+ }
85
364
  function sameIdentity(session, value) {
86
365
  const identity = session.nativeIdentity;
87
366
  return identity.generation === value.generation && identity.owner === value.owner &&
@@ -111,7 +390,9 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
111
390
  const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
112
391
  peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
113
392
  executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
114
- status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
393
+ status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {},
394
+ metrics: value.snapshot.metrics || null, runtimeMetadata: value.snapshot.runtimeMetadata || null,
395
+ account: value.snapshot.account || null, pendingApprovals: value.snapshot.pendingApprovals || [],
115
396
  quarantined: Boolean(value.quarantined), closing: Boolean(value.closing),
116
397
  activeDelivery: value.active_delivery || null, events: history.slice(-HISTORY_LIMIT),
117
398
  deliveries: value.deliveries.slice(0, 100), eventCursor: cursor,
@@ -141,16 +422,21 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
141
422
  generation: session.nativeIdentity.generation, owner: session.nativeIdentity.owner });
142
423
  const value = await nativeApi(session.ctx, 'GET', `/workers/${encodeURIComponent(session.peerId)}/state?${params}`);
143
424
  current(session);
144
- return applyState(session, validateState(session.ctx, session.peerId, value));
425
+ const checked = await validateObservedState(session.ctx, session.peerId, value);
426
+ current(session);
427
+ return applyState(session, checked);
145
428
  }
146
429
  async function scan(ctx) {
430
+ // A status response can predate a worker created while the read was in
431
+ // flight. Only retire views that this scan already knew when it started.
432
+ const observed = new Set(sessions.values());
147
433
  let status;
148
434
  try {
149
435
  status = await nativeApi(ctx, 'GET', '/status', null, { timeoutMs: 2000 });
150
436
  assertProject(ctx, status);
151
437
  if (!Array.isArray(status.workers)) throw new CliError('NATIVE_RESPONSE_INVALID', 'Native worker list is invalid');
152
438
  } catch (error) {
153
- if (!stopped) for (const session of projectSessions(ctx)) disconnect(session, error,
439
+ if (!stopped) for (const session of projectSessions(ctx).filter(session => observed.has(session))) disconnect(session, error,
154
440
  ['NATIVE_SCOPE_CHANGED', 'NATIVE_OWNER_CHANGED'].includes(error.code));
155
441
  return projectSessions(ctx);
156
442
  }
@@ -158,7 +444,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
158
444
  const owned = status.workers.filter((worker) => worker.owned === true && PROVIDERS.has(worker.provider) &&
159
445
  typeof worker.peer === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(worker.peer));
160
446
  const present = new Set(owned.map((worker) => worker.peer));
161
- for (const session of projectSessions(ctx)) {
447
+ for (const session of projectSessions(ctx).filter(session => observed.has(session))) {
162
448
  if (session.nativeIdentity.generation !== status.generation || !present.has(session.peerId)) {
163
449
  disconnect(session, new CliError('NATIVE_OWNER_CHANGED', 'Native worker is no longer owned by this service'), true);
164
450
  }
@@ -167,14 +453,26 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
167
453
  const key = sessionKey(ctx, worker.peer);
168
454
  const existing = sessions.get(key);
169
455
  if (existing && existing.type !== 'native') return;
456
+ // The list itself may also predate a newly installed view. Do not use
457
+ // its old owner to read or disconnect that newer view.
458
+ if (existing && !observed.has(existing)) return;
459
+ const wasRetired = Boolean(existing?.nativeRetired);
460
+ const wasExited = existing?.status === 'exited';
170
461
  try {
171
462
  const sameOwner = existing && !existing.nativeRetired && existing.nativeIdentity.owner === worker.owner &&
172
463
  existing.nativeIdentity.generation === status.generation;
173
464
  const after = sameOwner ? existing.nativeState?.eventCursor || 0 : 0;
174
465
  const params = new URLSearchParams({ after: String(after), generation: status.generation, owner: worker.owner });
175
- const value = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(worker.peer)}/state?${params}`, null, { timeoutMs: 2000 });
466
+ let value = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(worker.peer)}/state?${params}`, null, { timeoutMs: 2000 });
176
467
  if (stopped) return;
177
- const checked = validateState(ctx, worker.peer, value);
468
+ const checked = await validateObservedState(ctx, worker.peer, value);
469
+ if (stopped) return;
470
+ // Creation or restore can install a view while these reads await the
471
+ // daemon. This scan must not replace that view and orphan its clients
472
+ // or control tokens, nor revive a view closed or retired during the read.
473
+ if (sessions.get(key) !== existing || Boolean(existing?.nativeRetired) !== wasRetired ||
474
+ (existing?.status === 'exited') !== wasExited) return;
475
+ value = checked.value;
178
476
  if (existing && !existing.nativeRetired && sameIdentity(existing, value)) applyState(existing, checked);
179
477
  else {
180
478
  if (existing && !existing.nativeRetired) disconnect(existing,
@@ -182,7 +480,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
182
480
  makeSession(ctx, checked);
183
481
  }
184
482
  } catch (error) {
185
- if (!stopped && existing && !existing.nativeRetired) disconnect(existing, error,
483
+ if (!stopped && existing && sessions.get(key) === existing && !existing.nativeRetired) disconnect(existing, error,
186
484
  ['NATIVE_SCOPE_CHANGED', 'NATIVE_OWNER_CHANGED', 'NATIVE_WORKER_NOT_FOUND'].includes(error.code));
187
485
  }
188
486
  }));
@@ -210,7 +508,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
210
508
  if (session?.type !== 'native') throw new CliError('BAD_REQUEST', 'A native worker view is required');
211
509
  current(session);
212
510
  if (action === 'state') return session.nativeSnapshot();
213
- if (!['read', 'send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native worker action');
511
+ if (!['read', 'account', 'send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native worker action');
214
512
  let text, submissionId;
215
513
  if (action === 'send') {
216
514
  text = typeof input.text === 'string' ? input.text.trim() : typeof input.body === 'string' ? input.body.trim() : '';
@@ -226,6 +524,16 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
226
524
  throw error;
227
525
  }
228
526
  if (action === 'read') return session.nativeSnapshot();
527
+ if (action === 'account') {
528
+ if (input.generation !== session.nativeIdentity.generation || input.owner !== session.nativeIdentity.owner ||
529
+ input.sessionId !== session.nativeIdentity.sessionId) throw new CliError('NATIVE_OWNER_CHANGED', 'Account view belongs to another native executor');
530
+ if (!session.nativeState.capabilities.accountRead) throw new CliError('NATIVE_CAPABILITY_UNSUPPORTED', 'Provider does not expose account status');
531
+ const params = new URLSearchParams({ after: String(session.nativeState.eventCursor || 0),
532
+ generation: session.nativeIdentity.generation, owner: session.nativeIdentity.owner, sessionId: session.nativeIdentity.sessionId });
533
+ const value = await nativeApi(session.ctx, 'GET', `/workers/${encodeURIComponent(session.peerId)}/account?${params}`);
534
+ current(session);
535
+ return applyState(session, validateState(session.ctx, session.peerId, value));
536
+ }
229
537
  if (session.status !== 'running') throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Native worker is not open');
230
538
  if (typeof input.authorizeMutation === 'function') input.authorizeMutation();
231
539
  const identity = { peer: session.peerId, generation: session.nativeIdentity.generation,
@@ -257,7 +565,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
257
565
  ...identity, ...(action === 'send' ? { from: 'web', body: text, submissionId }
258
566
  : action === 'interrupt' ? { turnId: input.turnId || session.nativeState.turnId || undefined }
259
567
  : action === 'respond' ? { executorId: input.executorId, requestId: input.requestId, turnId: input.turnId,
260
- decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers } : {})
568
+ decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content } : {})
261
569
  });
262
570
  if (action === 'send') {
263
571
  if (!Number.isSafeInteger(result?.message_id) || result.submission_id !== submissionId || typeof result.state !== 'string') {
@@ -289,5 +597,6 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
289
597
  }
290
598
  projects.clear();
291
599
  }
292
- return { discoverNativeSessions, pollNativeSessions, nativeAction, closeNativeBridge };
600
+ return { startNativeSession, listNativeHistory, nativeWorkerHistory, resumeNativeSession,
601
+ discoverNativeSessions, pollNativeSessions, nativeAction, closeNativeBridge };
293
602
  }