@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.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- 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
|
-
//
|
|
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
|
-
|
|
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 || {},
|
|
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
|
-
|
|
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
|
-
|
|
466
|
+
let value = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(worker.peer)}/state?${params}`, null, { timeoutMs: 2000 });
|
|
176
467
|
if (stopped) return;
|
|
177
|
-
const checked =
|
|
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 {
|
|
600
|
+
return { startNativeSession, listNativeHistory, nativeWorkerHistory, resumeNativeSession,
|
|
601
|
+
discoverNativeSessions, pollNativeSessions, nativeAction, closeNativeBridge };
|
|
293
602
|
}
|