@logicseek/hello-cc 1.0.0 → 1.0.2-dsh.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 (123) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +36 -0
  3. package/README.zh-CN.md +35 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/guide.md +58 -0
  11. package/docs/guide.zh-CN.md +55 -0
  12. package/docs/mcp.md +9 -0
  13. package/docs/mcp.zh-CN.md +23 -0
  14. package/docs/native.md +295 -0
  15. package/docs/native.zh-CN.md +253 -0
  16. package/docs/web-handoff.zh-CN.md +53 -0
  17. package/lib/cli/app.mjs +27 -6
  18. package/lib/cli/commands/coordination.mjs +3 -1
  19. package/lib/cli/commands/doctor.mjs +17 -2
  20. package/lib/cli/commands/dsh.mjs +79 -0
  21. package/lib/cli/commands/gc.mjs +182 -32
  22. package/lib/cli/commands/hook.mjs +241 -90
  23. package/lib/cli/commands/lock.mjs +34 -19
  24. package/lib/cli/commands/mcp.mjs +28 -0
  25. package/lib/cli/commands/misc.mjs +15 -0
  26. package/lib/cli/commands/native.mjs +130 -0
  27. package/lib/cli/commands/peer.mjs +21 -5
  28. package/lib/cli/commands/run.mjs +66 -21
  29. package/lib/cli/commands/task.mjs +6 -3
  30. package/lib/cli-args.mjs +4 -0
  31. package/lib/coordination-state.mjs +15 -1
  32. package/lib/core/coordination/event-retention.mjs +14 -2
  33. package/lib/core/coordination/gc-plan.mjs +3 -13
  34. package/lib/core/coordination/locks.mjs +45 -0
  35. package/lib/core/coordination/results.mjs +79 -0
  36. package/lib/core/peers/bindings.mjs +9 -3
  37. package/lib/diagnostics/codex.mjs +121 -0
  38. package/lib/integrations/codex-interactions.mjs +114 -0
  39. package/lib/integrations/dsh-collaboration.mjs +170 -0
  40. package/lib/integrations/dsh-cordis.d.ts +19 -0
  41. package/lib/integrations/dsh-cordis.mjs +177 -0
  42. package/lib/integrations/dsh.bundle.yml +7 -0
  43. package/lib/integrations/dsh.mjs +287 -0
  44. package/lib/integrations/native/claude.mjs +451 -0
  45. package/lib/integrations/native/codex.mjs +242 -0
  46. package/lib/integrations/native/dsh-acp.mjs +343 -0
  47. package/lib/integrations/native/index.mjs +19 -0
  48. package/lib/integrations/native/interactions.mjs +53 -0
  49. package/lib/integrations/native/jsonrpc.mjs +185 -0
  50. package/lib/integrations/peers/identity.mjs +36 -10
  51. package/lib/integrations/peers/processes.mjs +22 -8
  52. package/lib/integrations/providers.mjs +6 -2
  53. package/lib/mcp/scope.mjs +104 -0
  54. package/lib/mcp/stdio.mjs +80 -0
  55. package/lib/mcp/tools.mjs +197 -0
  56. package/lib/process/identity.mjs +33 -1
  57. package/lib/release/release-notes.mjs +9 -0
  58. package/lib/runtime/buffer-directory-lease.mjs +2 -1
  59. package/lib/runtime/client.mjs +3 -0
  60. package/lib/runtime/native/client.mjs +38 -0
  61. package/lib/runtime/native/service.mjs +511 -0
  62. package/lib/runtime/native/store.mjs +121 -0
  63. package/lib/shared/file-lock.mjs +11 -4
  64. package/lib/shared/socket-lock-worker.mjs +73 -24
  65. package/lib/terminal/tmux.mjs +53 -23
  66. package/lib/ui/help.mjs +5 -2
  67. package/lib/web/adoption-state.mjs +55 -0
  68. package/lib/web/auto-attach.mjs +18 -2
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +557 -0
  71. package/lib/web/codex-sessions.mjs +287 -0
  72. package/lib/web/control-lease.mjs +204 -0
  73. package/lib/web/http-routes.mjs +197 -15
  74. package/lib/web/http.mjs +5 -4
  75. package/lib/web/native-sessions.mjs +286 -0
  76. package/lib/web/project-contexts.mjs +8 -2
  77. package/lib/web/pty-sessions.mjs +60 -33
  78. package/lib/web/runtime-main.mjs +101 -6
  79. package/lib/web/session-serialize.mjs +0 -0
  80. package/lib/web/startup.mjs +4 -2
  81. package/lib/web/task-results.mjs +76 -0
  82. package/lib/web/tmux-sessions.mjs +92 -11
  83. package/lib/web/tmux-stream.mjs +2 -2
  84. package/lib/web/ui-codex.mjs +550 -0
  85. package/lib/web/ui-command-palette.mjs +119 -0
  86. package/lib/web/ui-handoff.mjs +102 -0
  87. package/lib/web/ui-history.mjs +118 -0
  88. package/lib/web/ui-i18n.mjs +841 -0
  89. package/lib/web/ui-interactions.mjs +78 -0
  90. package/lib/web/ui-login.mjs +101 -0
  91. package/lib/web/ui-native.mjs +165 -0
  92. package/lib/web/ui-preferences.mjs +278 -0
  93. package/lib/web/ui-review.mjs +132 -0
  94. package/lib/web/ui-template.mjs +1106 -576
  95. package/lib/web/ui-terminal-find.mjs +98 -0
  96. package/lib/web/ui-workbench.mjs +94 -0
  97. package/lib/web/ui-workspace.mjs +222 -0
  98. package/package.json +12 -4
  99. package/scripts/check-free-identifiers.mjs +67 -8
  100. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  101. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  102. package/scripts/dsh-installed-acceptance.mjs +247 -0
  103. package/scripts/dsh-official-acceptance.mjs +180 -0
  104. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  105. package/scripts/github-release.mjs +3 -1
  106. package/scripts/native-live-acceptance.mjs +369 -0
  107. package/scripts/regression.mjs +338 -187
  108. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  109. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  110. package/scripts/web-native-model-acceptance.mjs +138 -0
  111. package/docs/architecture-split-review.md +0 -217
  112. package/docs/current-state-review-2026-08.md +0 -341
  113. package/docs/defect-review-2026-07.md +0 -382
  114. package/docs/layout-split-stack-audit.md +0 -298
  115. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
  116. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
  117. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
  118. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
  119. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
  120. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
  121. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
  122. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
  123. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
@@ -0,0 +1,286 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../shared/errors.mjs';
4
+ import { redactSecrets } from '../shared/redact.mjs';
5
+ import { tx } from '../db/schema.mjs';
6
+ import { createEventHelpers } from '../db/events.mjs';
7
+ import { nativeRequest } from '../runtime/native/client.mjs';
8
+
9
+ const PROVIDERS = new Set(['codex', 'claude', 'dsh']);
10
+ const CLOSED = new Set(['closed', 'exited', 'stopped']);
11
+ const HISTORY_LIMIT = 200;
12
+
13
+ // A Web view of a worker already owned by the independent native service.
14
+ // This module never starts a service, provider process, or foreign TUI session.
15
+ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
16
+ broadcast = () => {}, closeSessionClients = () => {},
17
+ now = () => Math.floor(Date.now() / 1000), nativeApi = nativeRequest,
18
+ addEvent = createEventHelpers({ now }).addEvent } = {}) {
19
+ const projects = new Map();
20
+ const scans = new Map();
21
+ let stopped = false;
22
+
23
+ function projectKey(ctx) { return `${ctx.root}\0${path.resolve(ctx.dbPath)}`; }
24
+ function projectSessions(ctx) {
25
+ return [...sessions.values()].filter((session) => session.type === 'native' &&
26
+ projectKey(session.ctx) === projectKey(ctx) && !session.nativeRetired);
27
+ }
28
+ function errorInfo(error) {
29
+ return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
30
+ message: error.message || 'Native runtime is disconnected' });
31
+ }
32
+ function disconnect(session, error, retire = false) {
33
+ session.status = 'disconnected';
34
+ session.nativeState = { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) };
35
+ broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
36
+ if (retire && !session.nativeRetired) {
37
+ session.nativeRetired = true;
38
+ closeSessionClients(session);
39
+ session.actionTokens.clear();
40
+ session.actionTokenSockets?.clear();
41
+ }
42
+ }
43
+ function assertProject(ctx, value) {
44
+ if (!value || value.root !== fs.realpathSync(ctx.root) || value.meshDb !== path.resolve(ctx.dbPath) ||
45
+ typeof value.generation !== 'string' || !value.generation || value.generation.length > 128) {
46
+ throw new CliError('NATIVE_SCOPE_CHANGED', 'Native runtime does not match this project');
47
+ }
48
+ }
49
+ function validateState(ctx, peer, value) {
50
+ assertProject(ctx, value);
51
+ if (value.peer !== peer || !PROVIDERS.has(value.provider) ||
52
+ typeof value.owner !== 'string' || !value.owner || value.owner.length > 512 ||
53
+ !value.snapshot || value.snapshot.provider !== value.provider ||
54
+ typeof value.snapshot.status !== 'string' ||
55
+ (value.snapshot.sessionId != null && typeof value.snapshot.sessionId !== 'string') ||
56
+ !Array.isArray(value.events) || !Array.isArray(value.deliveries) ||
57
+ value.events.some((event) => event.peer !== peer || !Number.isSafeInteger(event.id) || event.id < 1) ||
58
+ value.deliveries.some((delivery) => delivery.peer !== peer)) {
59
+ throw new CliError('NATIVE_RESPONSE_INVALID', 'Native state is not scoped to the requested worker');
60
+ }
61
+ const db = connectWebProject(ctx);
62
+ try {
63
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer=?').get(peer);
64
+ // A resumed Claude SDK query initializes lazily on its first input. The
65
+ // service retains its saved binding while the owned adapter is ready
66
+ // with no confirmed session ID; its init event must confirm that ID.
67
+ const lazyClaudeResume = value.provider === 'claude' && value.snapshot.status === 'ready' &&
68
+ value.snapshot.sessionId == null;
69
+ if (!binding || binding.transport !== 'native' || binding.provider !== value.provider ||
70
+ binding.runtime_target !== value.owner ||
71
+ ((binding.provider_session_id || null) !== (value.snapshot.sessionId || null) && !lazyClaudeResume)) {
72
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Native worker binding changed; refresh the current worker');
73
+ }
74
+ return { value, binding: { ...binding } };
75
+ } finally { db.close(); }
76
+ }
77
+ function sameIdentity(session, value) {
78
+ const identity = session.nativeIdentity;
79
+ return identity.generation === value.generation && identity.owner === value.owner &&
80
+ identity.provider === value.provider &&
81
+ (!identity.sessionId || identity.sessionId === (value.snapshot.sessionId || null));
82
+ }
83
+ function current(session) {
84
+ if (stopped || session.nativeRetired || sessions.get(sessionKey(session.ctx, session.id)) !== session) {
85
+ throw new CliError('NATIVE_OWNER_CHANGED', 'This Web view no longer owns the current native worker');
86
+ }
87
+ }
88
+ function applyState(session, { value, binding }) {
89
+ current(session);
90
+ if (!sameIdentity(session, value)) {
91
+ const error = new CliError('NATIVE_OWNER_CHANGED', 'Native worker ownership changed');
92
+ disconnect(session, error, true);
93
+ throw error;
94
+ }
95
+ if (value.snapshot.sessionId) session.nativeIdentity.sessionId = value.snapshot.sessionId;
96
+ const events = new Map((session.nativeState?.events || []).map((event) => [event.id, event]));
97
+ for (const event of value.events) events.set(event.id, event);
98
+ const history = [...events.values()].sort((a, b) => a.id - b.id);
99
+ const cursor = Math.max(session.nativeState?.eventCursor || 0, ...value.events.map((event) => event.id));
100
+ const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
101
+ session.binding = binding;
102
+ session.cwd = value.cwd;
103
+ session.nativeState = redactSecrets({ backend: 'native', connected: true, root: value.root,
104
+ peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
105
+ executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
106
+ status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
107
+ quarantined: Boolean(value.quarantined), closing: Boolean(value.closing),
108
+ activeDelivery: value.active_delivery || null, events: history.slice(-HISTORY_LIMIT),
109
+ deliveries: value.deliveries.slice(0, 100), eventCursor: cursor,
110
+ truncated: Boolean(session.nativeState?.truncated || history.length > HISTORY_LIMIT) });
111
+ session.status = CLOSED.has(value.snapshot.status) ? 'exited'
112
+ : value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
113
+ if (session.status === 'exited') session.exitedAt = now();
114
+ broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
115
+ return session.nativeSnapshot();
116
+ }
117
+ function makeSession(ctx, checked) {
118
+ const { value, binding } = checked;
119
+ const session = { id: value.peer, peerId: value.peer, root: ctx.root, ctx,
120
+ kind: value.provider, role: 'native-worker', type: 'native', command: null,
121
+ cwd: value.cwd, pid: null, status: 'running', createdAt: now(),
122
+ clients: new Set(), actionTokens: new Set(), buffer: '', binding,
123
+ nativeIdentity: { generation: value.generation, owner: value.owner,
124
+ provider: value.provider, sessionId: value.snapshot.sessionId || null }, nativeState: null };
125
+ session.nativeSnapshot = () => structuredClone(session.nativeState);
126
+ sessions.set(sessionKey(ctx, session.id), session);
127
+ applyState(session, checked);
128
+ return session;
129
+ }
130
+ async function readState(session) {
131
+ current(session);
132
+ const params = new URLSearchParams({ after: String(session.nativeState?.eventCursor || 0),
133
+ generation: session.nativeIdentity.generation, owner: session.nativeIdentity.owner });
134
+ const value = await nativeApi(session.ctx, 'GET', `/workers/${encodeURIComponent(session.peerId)}/state?${params}`);
135
+ current(session);
136
+ return applyState(session, validateState(session.ctx, session.peerId, value));
137
+ }
138
+ async function scan(ctx) {
139
+ let status;
140
+ try {
141
+ status = await nativeApi(ctx, 'GET', '/status', null, { timeoutMs: 2000 });
142
+ assertProject(ctx, status);
143
+ if (!Array.isArray(status.workers)) throw new CliError('NATIVE_RESPONSE_INVALID', 'Native worker list is invalid');
144
+ } catch (error) {
145
+ if (!stopped) for (const session of projectSessions(ctx)) disconnect(session, error,
146
+ ['NATIVE_SCOPE_CHANGED', 'NATIVE_OWNER_CHANGED'].includes(error.code));
147
+ return projectSessions(ctx);
148
+ }
149
+ if (stopped) return [];
150
+ const owned = status.workers.filter((worker) => worker.owned === true && PROVIDERS.has(worker.provider) &&
151
+ typeof worker.peer === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(worker.peer));
152
+ const present = new Set(owned.map((worker) => worker.peer));
153
+ for (const session of projectSessions(ctx)) {
154
+ if (session.nativeIdentity.generation !== status.generation || !present.has(session.peerId)) {
155
+ disconnect(session, new CliError('NATIVE_OWNER_CHANGED', 'Native worker is no longer owned by this service'), true);
156
+ }
157
+ }
158
+ await Promise.allSettled(owned.map(async (worker) => {
159
+ const key = sessionKey(ctx, worker.peer);
160
+ const existing = sessions.get(key);
161
+ if (existing && existing.type !== 'native') return;
162
+ try {
163
+ const sameOwner = existing && !existing.nativeRetired && existing.nativeIdentity.owner === worker.owner &&
164
+ existing.nativeIdentity.generation === status.generation;
165
+ const after = sameOwner ? existing.nativeState?.eventCursor || 0 : 0;
166
+ const params = new URLSearchParams({ after: String(after), generation: status.generation, owner: worker.owner });
167
+ const value = await nativeApi(ctx, 'GET', `/workers/${encodeURIComponent(worker.peer)}/state?${params}`, null, { timeoutMs: 2000 });
168
+ if (stopped) return;
169
+ const checked = validateState(ctx, worker.peer, value);
170
+ if (existing && !existing.nativeRetired && sameIdentity(existing, value)) applyState(existing, checked);
171
+ else {
172
+ if (existing && !existing.nativeRetired) disconnect(existing,
173
+ new CliError('NATIVE_OWNER_CHANGED', 'Native worker ownership changed'), true);
174
+ makeSession(ctx, checked);
175
+ }
176
+ } catch (error) {
177
+ if (!stopped && existing && !existing.nativeRetired) disconnect(existing, error,
178
+ ['NATIVE_SCOPE_CHANGED', 'NATIVE_OWNER_CHANGED', 'NATIVE_WORKER_NOT_FOUND'].includes(error.code));
179
+ }
180
+ }));
181
+ return projectSessions(ctx);
182
+ }
183
+ function discoverNativeSessions(ctx) {
184
+ if (stopped) return Promise.resolve([]);
185
+ const key = projectKey(ctx);
186
+ projects.set(key, ctx);
187
+ if (scans.has(key)) return scans.get(key);
188
+ const operation = scan(ctx).finally(() => { if (scans.get(key) === operation) scans.delete(key); });
189
+ scans.set(key, operation);
190
+ return operation;
191
+ }
192
+ async function pollNativeSessions(ctx) {
193
+ if (ctx) return discoverNativeSessions(ctx);
194
+ const results = await Promise.allSettled([...projects.values()].map(discoverNativeSessions));
195
+ return results.flatMap((result) => result.status === 'fulfilled' ? result.value : []);
196
+ }
197
+ function receiptEvent(session, type, payload) {
198
+ const db = connectWebProject(session.ctx);
199
+ try { addEvent(db, type, session.peerId, null, payload); } finally { db.close(); }
200
+ }
201
+ async function nativeAction(session, action, input = {}) {
202
+ if (session?.type !== 'native') throw new CliError('BAD_REQUEST', 'A native worker view is required');
203
+ current(session);
204
+ if (action === 'state') return session.nativeSnapshot();
205
+ if (!['read', 'send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native worker action');
206
+ let text, submissionId;
207
+ if (action === 'send') {
208
+ text = typeof input.text === 'string' ? input.text.trim() : typeof input.body === 'string' ? input.body.trim() : '';
209
+ submissionId = input.submissionId;
210
+ if (!text || text.length > 100000 || typeof submissionId !== 'string' || !/^[A-Za-z0-9_-]{8,100}$/.test(submissionId)) {
211
+ throw new CliError('BAD_REQUEST', 'A message and a unique submissionId are required');
212
+ }
213
+ }
214
+ try { await readState(session); }
215
+ catch (error) {
216
+ if (!session.nativeRetired) disconnect(session, error,
217
+ ['NATIVE_OWNER_CHANGED', 'NATIVE_SCOPE_CHANGED', 'NATIVE_WORKER_NOT_FOUND'].includes(error.code));
218
+ throw error;
219
+ }
220
+ if (action === 'read') return session.nativeSnapshot();
221
+ if (session.status !== 'running') throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Native worker is not open');
222
+ if (typeof input.authorizeMutation === 'function') input.authorizeMutation();
223
+ const identity = { peer: session.peerId, generation: session.nativeIdentity.generation,
224
+ owner: session.nativeIdentity.owner, provider: session.kind, sessionId: session.nativeIdentity.sessionId };
225
+ if (action === 'respond' && (input.executorId !== session.nativeState.executorId || input.sessionId !== session.nativeState.sessionId)) {
226
+ throw new CliError('NATIVE_APPROVAL_MISMATCH', 'Interaction belongs to another executor or provider session');
227
+ }
228
+ if (action === 'interrupt' && !session.nativeState.capabilities.interrupt) {
229
+ throw new CliError('NATIVE_CAPABILITY_UNSUPPORTED', 'Provider does not support interrupt');
230
+ }
231
+ const pending = { submission_id: submissionId, owner: identity.owner, generation: identity.generation,
232
+ provider: identity.provider, provider_session_id: identity.sessionId };
233
+ if (action === 'send') {
234
+ const db = connectWebProject(session.ctx);
235
+ try {
236
+ tx(db, () => {
237
+ if (db.prepare(`SELECT id FROM events WHERE type='native.web.submission.pending'
238
+ AND json_extract(payload, '$.submission_id')=? LIMIT 1`).get(submissionId)) {
239
+ throw new CliError('SUBMISSION_EXISTS', 'This submission was already attempted; inspect native deliveries before sending again');
240
+ }
241
+ addEvent(db, 'native.web.submission.pending', session.peerId, null, pending);
242
+ });
243
+ } finally { db.close(); }
244
+ }
245
+ current(session);
246
+ if (typeof input.authorizeMutation === 'function') input.authorizeMutation();
247
+ try {
248
+ const result = await nativeApi(session.ctx, 'POST', `/${action === 'send' ? 'send' : action}`, {
249
+ ...identity, ...(action === 'send' ? { from: 'web', body: text, submissionId }
250
+ : action === 'interrupt' ? { turnId: input.turnId || session.nativeState.turnId || undefined }
251
+ : action === 'respond' ? { executorId: input.executorId, requestId: input.requestId, turnId: input.turnId,
252
+ decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers } : {})
253
+ });
254
+ if (action === 'send') {
255
+ if (!Number.isSafeInteger(result?.message_id) || result.submission_id !== submissionId || typeof result.state !== 'string') {
256
+ throw new CliError('NATIVE_RESPONSE_INVALID', 'Native queue receipt is invalid', { uncertain: true });
257
+ }
258
+ receiptEvent(session, 'native.web.submission.queued', { ...pending, message_id: result.message_id, state: result.state });
259
+ }
260
+ current(session);
261
+ if (action === 'respond') { await readState(session); current(session); }
262
+ if (action === 'close') {
263
+ session.status = 'exited'; session.exitedAt = now();
264
+ session.nativeState = { ...session.nativeState, connected: false, status: 'closed', closing: false };
265
+ broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
266
+ closeSessionClients(session);
267
+ session.actionTokens.clear();
268
+ }
269
+ return result;
270
+ } catch (error) {
271
+ if (action === 'send') receiptEvent(session, 'native.web.submission.unconfirmed', { ...pending, code: error.code || 'UNKNOWN' });
272
+ if (['NATIVE_OWNER_CHANGED', 'NATIVE_SCOPE_CHANGED'].includes(error.code) && !session.nativeRetired) disconnect(session, error, true);
273
+ throw error;
274
+ }
275
+ }
276
+ function closeNativeBridge() {
277
+ stopped = true;
278
+ for (const session of sessions.values()) {
279
+ if (session.type === 'native' && !session.nativeRetired) disconnect(session,
280
+ new CliError('NATIVE_WEB_DISCONNECTED', 'Web disconnected; the independent native worker keeps running'));
281
+ if (session.type === 'native') closeSessionClients(session);
282
+ }
283
+ projects.clear();
284
+ }
285
+ return { discoverNativeSessions, pollNativeSessions, nativeAction, closeNativeBridge };
286
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import path from 'node:path';
7
7
  import { randomBytes } from 'node:crypto';
8
+ import WebSocket from 'ws';
8
9
  import { CliError } from '../shared/errors.mjs';
9
10
  import { contextForProject } from '../runtime/paths.mjs';
10
11
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
@@ -27,7 +28,7 @@ export function createProjectContexts(deps) {
27
28
  observePeerEvidence, peerEvidenceFromDb,
28
29
  observeClockSafetyInTransaction,
29
30
  assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
30
- positiveIntOpt, sameResolvedPath
31
+ positiveIntOpt, sameResolvedPath, assertSessionControl
31
32
  } = deps;
32
33
 
33
34
  const projectContexts = new Map();
@@ -145,10 +146,15 @@ export function createProjectContexts(deps) {
145
146
  }
146
147
  const provided = readActionToken(input, req);
147
148
  const authorized = Boolean(provided) && [...(session.actionTokens || [])]
148
- .some((candidate) => tokenMatches(provided, candidate));
149
+ .some((candidate) => {
150
+ if (!tokenMatches(provided, candidate)) return false;
151
+ const socket = session.actionTokenSockets?.get(candidate);
152
+ return socket?.readyState === WebSocket.OPEN;
153
+ });
149
154
  if (!authorized) {
150
155
  throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action for " + peer + " requires the managed session action token", { peer });
151
156
  }
157
+ assertSessionControl?.(session, provided, input.epoch);
152
158
  return actorPeer;
153
159
  }
154
160
 
@@ -14,6 +14,7 @@ import {
14
14
  import { providerSessionPeerId } from '../core/peers/session.mjs';
15
15
  import { childSessionEnv } from '../core/sessions/launch.mjs';
16
16
  import { detectBranch } from '../project-context.mjs';
17
+ import { tx } from '../db/schema.mjs';
17
18
 
18
19
  export function createPtySessions(deps) {
19
20
  const {
@@ -26,6 +27,18 @@ export function createPtySessions(deps) {
26
27
  requestActorPeer, requestSource
27
28
  } = deps;
28
29
 
30
+ function assertPtyOwnerAllowed(db, id, binding = {}) {
31
+ const owner = db.prepare('SELECT transport FROM peer_bindings WHERE peer = ?').get(id);
32
+ const providerOwner = binding && (binding.provider_session_id || binding.provider_session_name)
33
+ ? db.prepare(`SELECT transport FROM peer_bindings WHERE provider = ? AND
34
+ ((? IS NOT NULL AND provider_session_id = ?) OR (? IS NOT NULL AND provider_session_name = ?)) LIMIT 1`)
35
+ .get(binding.provider, binding.provider_session_id || null, binding.provider_session_id || null,
36
+ binding.provider_session_name || null, binding.provider_session_name || null) : null;
37
+ if ([owner, providerOwner].some(row => ['native', 'app-server'].includes(row?.transport))) {
38
+ throw new CliError('STRUCTURED_SESSION_NO_TERMINAL', 'Open the structured worker instead of replacing its binding with a PTY');
39
+ }
40
+ }
41
+
29
42
  function startPtySession(input) {
30
43
  const pctx = input.projectCtx || ctx;
31
44
  const kind = input.kind || 'shell';
@@ -33,6 +46,8 @@ export function createPtySessions(deps) {
33
46
  const actorPeer = requestActorPeer(input, id);
34
47
  const auditSource = requestSource(input, 'web');
35
48
  const key = sessionKey(pctx, id);
49
+ const preflightDb = connectWebProject(pctx);
50
+ try { assertPtyOwnerAllowed(preflightDb, id, input.binding); } finally { preflightDb.close(); }
36
51
  if (sessions.has(key) && sessions.get(key).status === 'running') {
37
52
  return sessions.get(key);
38
53
  }
@@ -48,6 +63,7 @@ export function createPtySessions(deps) {
48
63
  TERM: 'xterm-256color'
49
64
  }, callerEnv);
50
65
  const size = input.size || { cols: 100, rows: 30 };
66
+ const previousSession = sessions.get(key);
51
67
  const child = pty.spawn(shell, ['-c', command], {
52
68
  name: 'xterm-256color',
53
69
  cols: size.cols,
@@ -76,42 +92,53 @@ export function createPtySessions(deps) {
76
92
  clients: new Set()
77
93
  };
78
94
  sessions.set(key, session);
79
- const db = connectWebProject(pctx);
95
+ let db;
80
96
  try {
81
- upsertPeer(db, {
82
- id,
83
- kind,
84
- role,
85
- worktree: cwd,
86
- branch: detectBranch(cwd),
87
- pid: child.pid,
88
- status: 'running',
89
- capabilities: 'web-pty'
97
+ db = connectWebProject(pctx);
98
+ tx(db, () => {
99
+ assertPtyOwnerAllowed(db, id, input.binding);
100
+ upsertPeer(db, {
101
+ id,
102
+ kind,
103
+ role,
104
+ worktree: cwd,
105
+ branch: detectBranch(cwd),
106
+ pid: child.pid,
107
+ status: 'running',
108
+ capabilities: 'web-pty'
109
+ });
110
+ const canonical = upsertCanonicalPeerBinding(db, {
111
+ peer: id,
112
+ provider: input.binding?.provider || kind,
113
+ provider_session_id: input.binding?.provider_session_id || null,
114
+ provider_session_name: input.binding?.provider_session_name || null,
115
+ resume_mode: input.binding?.resume_mode || 'new',
116
+ resume_arg: input.binding?.resume_arg || null,
117
+ command,
118
+ transport: 'web-pty',
119
+ runtime_session_id: id
120
+ }, Boolean(input.force));
121
+ session.peerId = canonical.peer;
122
+ session.binding = { ...canonical.binding };
123
+ addEvent(db, 'web.session.started', actorPeer, null, auditPayload({
124
+ actor: actorPeer,
125
+ target: session.peerId || id,
126
+ source: auditSource,
127
+ admin: actorPeer !== (session.peerId || id),
128
+ command,
129
+ cwd,
130
+ pid: child.pid
131
+ }));
90
132
  });
91
- const canonical = upsertCanonicalPeerBinding(db, {
92
- peer: id,
93
- provider: input.binding?.provider || kind,
94
- provider_session_id: input.binding?.provider_session_id || null,
95
- provider_session_name: input.binding?.provider_session_name || null,
96
- resume_mode: input.binding?.resume_mode || 'new',
97
- resume_arg: input.binding?.resume_arg || null,
98
- command,
99
- transport: 'web-pty',
100
- runtime_session_id: id
101
- }, Boolean(input.force));
102
- session.peerId = canonical.peer;
103
- session.binding = { ...canonical.binding };
104
- addEvent(db, 'web.session.started', actorPeer, null, auditPayload({
105
- actor: actorPeer,
106
- target: session.peerId || id,
107
- source: auditSource,
108
- admin: actorPeer !== (session.peerId || id),
109
- command,
110
- cwd,
111
- pid: child.pid
112
- }));
133
+ } catch (error) {
134
+ try { child.kill(); } catch {}
135
+ if (sessions.get(key) === session) {
136
+ if (previousSession) sessions.set(key, previousSession);
137
+ else sessions.delete(key);
138
+ }
139
+ throw error;
113
140
  } finally {
114
- db.close();
141
+ db?.close();
115
142
  }
116
143
  child.onData((data) => {
117
144
  session.buffer += data;
@@ -109,6 +109,10 @@ import { ensureSelfSignedCert } from '../web/tls.mjs';
109
109
  import * as webUiTemplate from '../web/ui-template.mjs';
110
110
  import { createWebPeerActions } from '../web/peer-actions.mjs';
111
111
  import { createCookieAuth } from '../web/cookie-auth.mjs';
112
+ import { createControlLease } from './control-lease.mjs';
113
+ import { createCodexSessions } from './codex-sessions.mjs';
114
+ import { createTaskResults } from './task-results.mjs';
115
+ import { createNativeSessions } from './native-sessions.mjs';
112
116
  import { createProjectContexts } from '../web/project-contexts.mjs';
113
117
  import { createExternalSessions } from '../web/external-sessions.mjs';
114
118
  import { createLivenessReaper } from '../web/liveness-reaper.mjs';
@@ -231,8 +235,36 @@ async function cmdWeb(ctx, args, startMeta = {}) {
231
235
  sessionKey, sessionsForProject,
232
236
  resolveSessionPeerId, sessionBindingForSerialize,
233
237
  serializeBindingSummary, serializeSession,
234
- broadcast, hasOpenClients, closeSessionClients
235
- } = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath });
238
+ broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
239
+ } = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
240
+ localClientObservation: (session) => {
241
+ try { return tmuxClientObservation(tmuxSessionNameForPane(session.pane)); }
242
+ catch { return { state: 'unknown', count: null }; }
243
+ }
244
+ });
245
+ const controlLease = createControlLease({ broadcast: (session) => {
246
+ for (const [actionToken, socket] of session.actionTokenSockets || []) {
247
+ if (cookieSocketValid(socket) && socket.readyState === 1) {
248
+ socket.send(JSON.stringify({ type: 'control', control: controlLease.status(session, actionToken) }));
249
+ }
250
+ }
251
+ } });
252
+ function closeSessionClients(session) {
253
+ controlLease.forget(session);
254
+ closeSerializedSessionClients(session);
255
+ }
256
+ function assertSessionControl(session, actionToken, epoch) {
257
+ try { return controlLease.assertControl(session, actionToken, epoch); }
258
+ catch (error) { throw new CliError(error.code || 'CONTROL_REQUIRED', error.message); }
259
+ }
260
+ function assertWebWrite(session, input, authMode, req) {
261
+ // Token CLI callers keep their admin path when no browser owns control.
262
+ // A browser lease also fences admin injection while it is held.
263
+ if (authMode !== 'cookie' && !controlLease.status(session).has_controller) return;
264
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'A request object is required');
265
+ const actionToken = String(input.action_token || input.actionToken || req?.headers['x-hcc-session-token'] || '');
266
+ assertSessionControl(session, actionToken, input.epoch);
267
+ }
236
268
  // refreshPeerIoHeartbeat comes from createTmuxSessions below; wrap it so the
237
269
  // reference resolves at call time instead of at this forward-passing site.
238
270
  const lazyRefreshPeerIoHeartbeat = (session) => refreshPeerIoHeartbeat(session);
@@ -254,7 +286,7 @@ const {
254
286
  observePeerEvidence, peerEvidenceFromDb,
255
287
  observeClockSafetyInTransaction,
256
288
  assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
257
- positiveIntOpt, sameResolvedPath
289
+ positiveIntOpt, sameResolvedPath, assertSessionControl
258
290
  });
259
291
 
260
292
  const {
@@ -394,8 +426,36 @@ const {
394
426
  upsertPeer, upsertCanonicalPeerBinding,
395
427
  requestActorPeer, requestSource
396
428
  });
429
+ const { readSessionResults, writeSessionResult, observeCodexResults } = createTaskResults({
430
+ connectWebProject, resolveSessionPeerId, now, addEvent
431
+ });
432
+ const { startCodexSession, codexAction, stopCodexSession,
433
+ listCodexThreads, readCodexThread, forkCodexThread } = createCodexSessions({
434
+ sessions, sessionKey, nextProjectSessionId, connectWebProject, upsertPeer,
435
+ upsertCanonicalPeerBinding, observePeerEvidence,
436
+ broadcast: (session, payload) => {
437
+ if (payload.type === 'codex_state') {
438
+ try { observeCodexResults(session, payload.state); }
439
+ catch (error) { console.error(redactedLogText(`Result evidence could not be saved: ${error.code || 'UNKNOWN'}`)); }
440
+ }
441
+ broadcast(session, payload);
442
+ }, closeSessionClients, now, addEvent
443
+ });
397
444
 
398
445
  const restoredTmuxDbs = new Set();
446
+ const { discoverNativeSessions, nativeAction, closeNativeBridge } = createNativeSessions({
447
+ sessions, sessionKey, connectWebProject, broadcast, closeSessionClients, now, addEvent
448
+ });
449
+ let nativeScanRunning = false;
450
+ async function scanNativeProjects() {
451
+ if (nativeScanRunning) return;
452
+ nativeScanRunning = true;
453
+ try { await Promise.allSettled(runtimeProjectContexts().map(discoverNativeSessions)); }
454
+ finally { nativeScanRunning = false; }
455
+ }
456
+ await scanNativeProjects();
457
+ const nativePoller = setInterval(() => { void scanNativeProjects(); }, 3000);
458
+ nativePoller.unref?.();
399
459
  for (const projectCtx of runtimeProjectContexts()) {
400
460
  const dbKey = path.resolve(projectCtx.dbPath);
401
461
  if (restoredTmuxDbs.has(dbKey)) continue;
@@ -416,6 +476,9 @@ const {
416
476
  prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
417
477
  detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
418
478
  killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
479
+ startCodexSession, codexAction, stopCodexSession, assertWebWrite,
480
+ listCodexThreads, readCodexThread, forkCodexThread, readSessionResults, writeSessionResult,
481
+ discoverNativeSessions, nativeAction,
419
482
  webStatusSnapshot, webPeerActionForProject,
420
483
  now, addEvent, auditPayload, sendMessage,
421
484
  getProcessIdentity: () => processIdentity,
@@ -499,30 +562,53 @@ const {
499
562
  return;
500
563
  }
501
564
  session.actionTokens ||= new Set();
565
+ session.actionTokenSockets ||= new Map();
502
566
  session.actionTokens.add(connectionActionToken);
503
- ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session), action_token: connectionActionToken }));
567
+ session.actionTokenSockets.set(connectionActionToken, ws);
568
+ controlLease.connect(session, connectionActionToken);
569
+ const localClients = session.type === 'tmux' ? serializeSession(session).local_clients : null;
570
+ ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session),
571
+ action_token: connectionActionToken, control: controlLease.status(session, connectionActionToken),
572
+ local_clients: localClients, ...(session.adapter ? { state: session.adapter.snapshot() } : {}),
573
+ ...(session.type === 'native' ? { native_state: session.nativeSnapshot() } : {}) }));
504
574
  ws.on('message', (raw) => {
505
575
  try {
506
576
  if (!cookieSocketValid(ws)) return;
507
577
  const msg = JSON.parse(String(raw));
508
578
  if (msg.type === 'input' && session.status === 'running') {
509
579
  if (!tokenMatches(msg.action_token, connectionActionToken)) return;
580
+ assertSessionControl(session, connectionActionToken, msg.epoch);
581
+ if (['app-server', 'native'].includes(session.type)) throw new CliError('BAD_REQUEST', 'Use structured worker actions');
510
582
  const data = String(msg.data || '');
583
+ if (data.length > 100000) throw new CliError('BAD_REQUEST', 'Terminal input is too large');
511
584
  writeSessionInput(session, data);
585
+ ws.send(JSON.stringify({ type: 'input_ack', input_id: msg.input_id || null }));
512
586
  } else if (msg.type === 'resize' && session.status === 'running') {
513
587
  if (!tokenMatches(msg.action_token, connectionActionToken)) return;
588
+ assertSessionControl(session, connectionActionToken, msg.epoch);
589
+ if (['app-server', 'native'].includes(session.type)) return;
514
590
  const cols = Math.max(20, Number.parseInt(msg.cols || 100, 10));
515
591
  const rows = Math.max(8, Number.parseInt(msg.rows || 30, 10));
592
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols > 1000 || rows > 1000) return;
516
593
  resizeSession(session, cols, rows);
517
594
  scheduleTmuxReplace(session);
595
+ } else if (msg.type === 'control') {
596
+ if (!tokenMatches(msg.action_token, connectionActionToken)) return;
597
+ if (msg.action === 'claim') controlLease.claim(session, connectionActionToken,
598
+ { epoch: msg.epoch, takeover: msg.force === true });
599
+ else if (msg.action === 'release') controlLease.release(session, connectionActionToken, msg.epoch);
518
600
  }
519
- } catch {
520
- // Ignore malformed terminal frames.
601
+ } catch (error) {
602
+ ws.send(JSON.stringify({ type: 'error', error: { code: error.code || 'BAD_REQUEST',
603
+ message: error.code ? error.message : 'Malformed terminal frame' },
604
+ control: controlLease.status(session, connectionActionToken) }));
521
605
  }
522
606
  });
523
607
  ws.on('close', () => {
524
608
  session.clients.delete(ws);
525
609
  session.actionTokens.delete(connectionActionToken);
610
+ session.actionTokenSockets.delete(connectionActionToken);
611
+ controlLease.disconnect(session, connectionActionToken);
526
612
  if (cookieAuth) cookieAuth.session.sockets.delete(ws);
527
613
  });
528
614
  });
@@ -576,10 +662,18 @@ const {
576
662
  clearInterval(reaperPoller);
577
663
  clearInterval(gcPoller);
578
664
  clearInterval(webSessionPruner);
665
+ clearInterval(nativePoller);
666
+ closeNativeBridge();
579
667
  try { for (const w of bufsWatchers.values()) w.close(); } catch {}
580
668
  clearInterval(bufsWatcherSyncPoller);
581
669
  for (const session of sessions.values()) {
670
+ controlLease.forget(session);
582
671
  closeSessionClients(session);
672
+ if (session.type === 'app-server') {
673
+ await session.adapter.close();
674
+ continue;
675
+ }
676
+ if (session.type === 'native') continue;
583
677
  if (session.status !== 'running') continue;
584
678
  if (session.type === 'external') {
585
679
  try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
@@ -641,6 +735,7 @@ const {
641
735
  // the next start. tmux sessions are intentionally left alive.
642
736
  process.on('exit', () => {
643
737
  for (const session of sessions.values()) {
738
+ if (session.type === 'app-server') void session.adapter.close();
644
739
  if (session.type !== 'tmux' && session.type !== 'external' && session.pty) {
645
740
  try { session.pty.kill(); } catch {}
646
741
  }
Binary file