@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -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/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -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
+ }
@@ -28,7 +28,7 @@ export function createProjectContexts(deps) {
28
28
  observePeerEvidence, peerEvidenceFromDb,
29
29
  observeClockSafetyInTransaction,
30
30
  assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
31
- positiveIntOpt, sameResolvedPath
31
+ positiveIntOpt, sameResolvedPath, assertSessionControl
32
32
  } = deps;
33
33
 
34
34
  const projectContexts = new Map();
@@ -154,6 +154,7 @@ export function createProjectContexts(deps) {
154
154
  if (!authorized) {
155
155
  throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action for " + peer + " requires the managed session action token", { peer });
156
156
  }
157
+ assertSessionControl?.(session, provided, input.epoch);
157
158
  return actorPeer;
158
159
  }
159
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,
@@ -502,30 +565,50 @@ const {
502
565
  session.actionTokenSockets ||= new Map();
503
566
  session.actionTokens.add(connectionActionToken);
504
567
  session.actionTokenSockets.set(connectionActionToken, ws);
505
- ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session), action_token: connectionActionToken }));
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() } : {}) }));
506
574
  ws.on('message', (raw) => {
507
575
  try {
508
576
  if (!cookieSocketValid(ws)) return;
509
577
  const msg = JSON.parse(String(raw));
510
578
  if (msg.type === 'input' && session.status === 'running') {
511
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');
512
582
  const data = String(msg.data || '');
583
+ if (data.length > 100000) throw new CliError('BAD_REQUEST', 'Terminal input is too large');
513
584
  writeSessionInput(session, data);
585
+ ws.send(JSON.stringify({ type: 'input_ack', input_id: msg.input_id || null }));
514
586
  } else if (msg.type === 'resize' && session.status === 'running') {
515
587
  if (!tokenMatches(msg.action_token, connectionActionToken)) return;
588
+ assertSessionControl(session, connectionActionToken, msg.epoch);
589
+ if (['app-server', 'native'].includes(session.type)) return;
516
590
  const cols = Math.max(20, Number.parseInt(msg.cols || 100, 10));
517
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;
518
593
  resizeSession(session, cols, rows);
519
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);
520
600
  }
521
- } catch {
522
- // 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) }));
523
605
  }
524
606
  });
525
607
  ws.on('close', () => {
526
608
  session.clients.delete(ws);
527
609
  session.actionTokens.delete(connectionActionToken);
528
610
  session.actionTokenSockets.delete(connectionActionToken);
611
+ controlLease.disconnect(session, connectionActionToken);
529
612
  if (cookieAuth) cookieAuth.session.sockets.delete(ws);
530
613
  });
531
614
  });
@@ -579,10 +662,18 @@ const {
579
662
  clearInterval(reaperPoller);
580
663
  clearInterval(gcPoller);
581
664
  clearInterval(webSessionPruner);
665
+ clearInterval(nativePoller);
666
+ closeNativeBridge();
582
667
  try { for (const w of bufsWatchers.values()) w.close(); } catch {}
583
668
  clearInterval(bufsWatcherSyncPoller);
584
669
  for (const session of sessions.values()) {
670
+ controlLease.forget(session);
585
671
  closeSessionClients(session);
672
+ if (session.type === 'app-server') {
673
+ await session.adapter.close();
674
+ continue;
675
+ }
676
+ if (session.type === 'native') continue;
586
677
  if (session.status !== 'running') continue;
587
678
  if (session.type === 'external') {
588
679
  try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
@@ -644,6 +735,7 @@ const {
644
735
  // the next start. tmux sessions are intentionally left alive.
645
736
  process.on('exit', () => {
646
737
  for (const session of sessions.values()) {
738
+ if (session.type === 'app-server') void session.adapter.close();
647
739
  if (session.type !== 'tmux' && session.type !== 'external' && session.pty) {
648
740
  try { session.pty.kill(); } catch {}
649
741
  }
Binary file