@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,287 @@
1
+ import process from 'node:process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CliError } from '../shared/errors.mjs';
6
+ import { tx } from '../db/schema.mjs';
7
+ import { childSessionEnv } from '../core/sessions/launch.mjs';
8
+ import { createCodexAppServer } from './codex-app-server.mjs';
9
+ import { waitForLiveProcessIdentity } from '../process/identity.mjs';
10
+ import { createScopedMcpConfig } from '../mcp/scope.mjs';
11
+
12
+ // Each structured session owns one executor. Existing TUI sessions are never
13
+ // silently resumed here: provider identity and live-owner evidence fence that.
14
+ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId,
15
+ connectWebProject, upsertPeer, upsertCanonicalPeerBinding, observePeerEvidence,
16
+ broadcast, closeSessionClients, now, addEvent, adapterFactory = createCodexAppServer,
17
+ waitForOwnerIdentity = waitForLiveProcessIdentity, mcpConfigFactory = createScopedMcpConfig }) {
18
+ const startingThreads = new Set();
19
+
20
+ function event(session, type, payload) {
21
+ const db = connectWebProject(session.ctx);
22
+ try { addEvent(db, type, session.peerId, null, payload); } finally { db.close(); }
23
+ }
24
+
25
+ async function startCodexSession(input) {
26
+ if (input.kind !== 'codex') throw new CliError('BAD_REQUEST', 'App Server supports Codex sessions only');
27
+ if (input.command || input.env || input.binding || input.force || input.providerForce) {
28
+ throw new CliError('BAD_REQUEST', 'App Server uses the local Codex executable and runtime environment');
29
+ }
30
+ const pctx = input.projectCtx;
31
+ const cwd = pctx.root;
32
+ const mode = input.mode || 'new';
33
+ if (!['new', 'resume'].includes(mode)) throw new CliError('BAD_REQUEST', 'App Server supports new or explicit resume');
34
+ const threadId = mode === 'resume' ? String(input.resume || '').trim() : null;
35
+ if (mode === 'resume' && (!threadId || input.handoffConfirmed !== true)) {
36
+ throw new CliError('HANDOFF_REQUIRED', 'Finish the old executor and explicitly confirm history handoff before resuming');
37
+ }
38
+ const reservation = threadId ? `codex:${threadId}` : `${pctx.root}\0${randomUUID()}`;
39
+ if (startingThreads.has(reservation)) throw new CliError('THREAD_IN_USE', 'This thread is already being opened');
40
+ const db = connectWebProject(pctx);
41
+ let previousPeer = null;
42
+ try {
43
+ if (threadId) {
44
+ for (const s of sessions.values()) {
45
+ const ownsExecutor = s.status === 'running' || (s.type === 'app-server' &&
46
+ !s.executorReleased && !s.adapter.snapshot().processExited);
47
+ if (ownsExecutor && s.binding?.provider_session_id === threadId) {
48
+ throw new CliError('THREAD_IN_USE', 'Open the existing managed session before resuming this thread');
49
+ }
50
+ }
51
+ const bindings = db.prepare(`SELECT p.*, b.transport, b.runtime_target FROM peer_bindings b
52
+ JOIN peers p ON p.id = b.peer WHERE b.provider = 'codex' AND b.provider_session_id = ?`).all(threadId);
53
+ for (const owner of bindings) {
54
+ if (observePeerEvidence(pctx, owner, owner).state !== 'dead') {
55
+ throw new CliError('THREAD_IN_USE', 'The old executor has not been confirmed stopped; open its existing terminal');
56
+ }
57
+ previousPeer = owner.id;
58
+ }
59
+ if (bindings.length > 1) throw new CliError('THREAD_IN_USE', 'Provider thread has ambiguous peer ownership');
60
+ }
61
+ } finally { db.close(); }
62
+ startingThreads.add(reservation);
63
+ const id = previousPeer || nextProjectSessionId(pctx, 'codex');
64
+ const session = { id, peerId: id, root: pctx.root, ctx: pctx, cwd, kind: 'codex', role: 'peer',
65
+ type: 'app-server', command: 'codex app-server --listen stdio://', status: 'starting',
66
+ createdAt: now(), clients: new Set(), actionTokens: new Set(), buffer: '' };
67
+ const key = sessionKey(pctx, id);
68
+ sessions.set(key, session);
69
+ let registered = false;
70
+ let closeScheduled = false;
71
+ let adapter;
72
+ let ownerIdentity = null;
73
+ try { adapter = adapterFactory({ cwd, env: childSessionEnv({
74
+ HCC_PEER: id, HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1'
75
+ }, process.env), async onSpawn({ pid, executorId }) {
76
+ const owner = await waitForOwnerIdentity(pid);
77
+ if (owner.state !== 'live' || !owner.identity) throw new CliError('CODEX_OWNER_IDENTITY_UNKNOWN', 'Executor ownership could not be verified');
78
+ ownerIdentity = owner.identity;
79
+ session.mcpCapability = mcpConfigFactory({ root: pctx.root, dbPath: pctx.dbPath,
80
+ peer: id, executorId, ownerIdentity,
81
+ binding: { transport: 'app-server', runtimeSessionId: id } });
82
+ return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
83
+ }, onChange(state) {
84
+ broadcast(session, { type: 'codex_state', state });
85
+ if (state.status === 'disconnected' && !closeScheduled) {
86
+ closeScheduled = true;
87
+ queueMicrotask(() => { if (adapter) void adapter.close(); });
88
+ }
89
+ if (!registered) return;
90
+ const ended = ['disconnected', 'closed'].includes(state.status);
91
+ if (ended) { session.status = 'exited'; session.exitedAt = now(); session.mcpCapability?.dispose(); }
92
+ const update = connectWebProject(pctx);
93
+ try {
94
+ if (ended) update.prepare("UPDATE peers SET status = 'exited' WHERE id = ?").run(session.peerId);
95
+ else update.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(now(), session.peerId);
96
+ } finally { update.close(); }
97
+ if (ended) broadcast(session, { type: 'exit', event: { reason: state.status } });
98
+ } }); } catch (error) {
99
+ sessions.delete(key); startingThreads.delete(reservation); throw error;
100
+ }
101
+ session.adapter = adapter;
102
+ try {
103
+ const params = { cwd, approvalPolicy: 'on-request', sandbox: 'workspace-write' };
104
+ if (threadId) {
105
+ const source = await adapter.peekThread(threadId);
106
+ assertProjectThread(pctx, source.thread, threadId);
107
+ }
108
+ const response = threadId ? await adapter.resumeThread(threadId, params) : await adapter.startThread(params);
109
+ const thread = response.thread;
110
+ if (!thread?.id) throw new CliError('CODEX_PROTOCOL_ERROR', 'App Server returned no thread identity');
111
+ if (threadId && thread.id !== threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Resumed thread identity changed');
112
+ const executorState = adapter.snapshot();
113
+ if (executorState.status !== 'ready' || executorState.processExited) {
114
+ throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
115
+ }
116
+ session.pid = executorState.pid || null;
117
+ const save = connectWebProject(pctx);
118
+ try {
119
+ tx(save, () => {
120
+ const owners = save.prepare(`SELECT p.*, b.transport, b.runtime_target FROM peer_bindings b
121
+ JOIN peers p ON p.id = b.peer WHERE b.provider = 'codex' AND b.provider_session_id = ?`).all(thread.id);
122
+ for (const owner of owners) {
123
+ if (observePeerEvidence(pctx, owner, owner).state !== 'dead') {
124
+ throw new CliError('THREAD_IN_USE', 'Thread ownership changed while its executor was opening');
125
+ }
126
+ }
127
+ upsertPeer(save, { id, kind: 'codex', role: 'peer', worktree: cwd, pid: session.pid,
128
+ ...(ownerIdentity ? { processIdentity: ownerIdentity } : {}),
129
+ status: 'running', capabilities: 'codex-app-server' });
130
+ const canonical = upsertCanonicalPeerBinding(save, { peer: id, provider: 'codex',
131
+ provider_session_id: thread.id, resume_mode: mode, resume_arg: threadId,
132
+ command: session.command, transport: 'app-server', runtime_session_id: id }, false, { override: Boolean(threadId) });
133
+ session.peerId = canonical.peer;
134
+ session.binding = { ...canonical.binding };
135
+ addEvent(save, 'codex.executor.started', session.peerId, null, {
136
+ executor_id: adapter.snapshot().executorId, thread_id: thread.id, mode, cwd
137
+ });
138
+ });
139
+ } finally { save.close(); }
140
+ session.status = 'running';
141
+ registered = true;
142
+ return session;
143
+ } catch (error) {
144
+ await adapter.close();
145
+ session.mcpCapability?.dispose();
146
+ if (sessions.get(key) === session) sessions.delete(key);
147
+ throw error;
148
+ } finally { startingThreads.delete(reservation); }
149
+ }
150
+
151
+ async function codexAction(session, action, input) {
152
+ if (session.type !== 'app-server' || session.status !== 'running') {
153
+ throw new CliError('SESSION_NOT_RUNNING', 'A running App Server session is required');
154
+ }
155
+ const adapter = session.adapter;
156
+ const threadId = session.binding.provider_session_id;
157
+ if (action === 'read') return adapter.readThread(threadId, { includeTurns: true });
158
+ if (action === 'interrupt') return adapter.interrupt(threadId, String(input.turnId || ''));
159
+ if (action === 'approve') return adapter.approve({ executorId: input.executorId,
160
+ threadId, turnId: input.turnId, requestId: input.requestId, decision: input.decision,
161
+ permissions: input.permissions, scope: input.scope, answers: input.answers });
162
+ if (!['turn', 'steer'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
163
+ const text = String(input.text || '').trim();
164
+ if (!text || text.length > 100000) throw new CliError('BAD_REQUEST', 'A message of 1 to 100000 characters is required');
165
+ const submissionId = String(input.submissionId || '');
166
+ if (!/^[A-Za-z0-9_-]{8,100}$/.test(submissionId)) throw new CliError('BAD_REQUEST', 'A unique submissionId is required');
167
+ const submission = { submission_id: submissionId, executor_id: adapter.snapshot().executorId,
168
+ thread_id: threadId, action };
169
+ const db = connectWebProject(session.ctx);
170
+ try {
171
+ if (db.prepare(`SELECT id FROM events WHERE type = 'codex.submission.pending'
172
+ AND json_extract(payload, '$.submission_id') = ? LIMIT 1`).get(submissionId)) {
173
+ throw new CliError('SUBMISSION_EXISTS', 'This submission was already attempted; read state before sending again');
174
+ }
175
+ // Commit receipt before calling the executor. A crashed request remains
176
+ // pending and will never be replayed automatically by this runtime.
177
+ addEvent(db, 'codex.submission.pending', session.peerId, null, submission);
178
+ } finally { db.close(); }
179
+ try {
180
+ const content = [{ type: 'text', text }];
181
+ const result = action === 'turn' ? await adapter.startTurn(threadId, content)
182
+ : await adapter.steer(threadId, String(input.turnId || ''), content);
183
+ event(session, 'codex.submission.confirmed', { ...submission, turn_id: result.turn?.id || input.turnId });
184
+ return { ...result, submissionId };
185
+ } catch (error) {
186
+ event(session, 'codex.submission.unconfirmed', { ...submission, code: error.code || 'UNKNOWN' });
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ async function stopCodexSession(session) {
192
+ session.mcpCapability?.dispose();
193
+ await session.adapter.close();
194
+ session.executorReleased = true;
195
+ session.status = 'exited'; session.exitedAt = now();
196
+ const db = connectWebProject(session.ctx);
197
+ try {
198
+ db.prepare("UPDATE peers SET status = 'exited' WHERE id = ?").run(session.peerId);
199
+ addEvent(db, 'codex.executor.stopped', session.peerId, null, { executor_id: session.adapter.snapshot().executorId });
200
+ } finally { db.close(); }
201
+ broadcast(session, { type: 'exit', event: { reason: 'executor stopped' } });
202
+ closeSessionClients(session);
203
+ }
204
+ function assertProjectThread(pctx, thread, expectedId = null) {
205
+ let matches = false;
206
+ try { matches = typeof thread?.cwd === 'string' && fs.realpathSync(thread.cwd) === fs.realpathSync(pctx.root); } catch {}
207
+ if (!matches || typeof thread?.id !== 'string' || (expectedId && thread.id !== expectedId)) {
208
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex thread does not belong to this project');
209
+ }
210
+ return thread;
211
+ }
212
+
213
+ function threadIdentifier(value) {
214
+ if (typeof value !== 'string' || !value.trim() || value.length > 512 || /[\r\n\0]/.test(value)) {
215
+ throw new CliError('BAD_REQUEST', 'A bounded thread identity is required');
216
+ }
217
+ return value;
218
+ }
219
+
220
+ async function withHistoryAdapter(pctx, action, fresh = false) {
221
+ const managed = !fresh && [...sessions.values()].find(session => session.type === 'app-server' &&
222
+ session.root === pctx.root && session.status === 'running' && session.adapter.snapshot().status === 'ready');
223
+ if (managed) return action(managed.adapter);
224
+ const adapter = adapterFactory({ cwd: pctx.root,
225
+ env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' }, process.env) });
226
+ try { return await action(adapter); } finally {
227
+ await adapter.close();
228
+ const state = adapter.snapshot();
229
+ if (state.pid && !state.processExited) {
230
+ throw new CliError('CODEX_EXECUTOR_CLOSE_UNCONFIRMED', 'History executor exit could not be confirmed; no second executor was started');
231
+ }
232
+ }
233
+ }
234
+
235
+ async function listCodexThreads(pctx, { cursor = null, limit = 50 } = {}) {
236
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 ||
237
+ (cursor !== null && (typeof cursor !== 'string' || cursor.length > 2048))) {
238
+ throw new CliError('BAD_REQUEST', 'History limit must be 1 to 100 and cursor must be bounded text');
239
+ }
240
+ const result = await withHistoryAdapter(pctx, adapter => adapter.listThreads({ cwd: pctx.root, limit, cursor }));
241
+ const threads = (result.data || []).filter(thread => {
242
+ try { assertProjectThread(pctx, thread); return true; } catch { return false; }
243
+ }).map(thread => {
244
+ const managed = [...sessions.values()].find(session => session.root === pctx.root &&
245
+ session.binding?.provider_session_id === thread.id && session.status === 'running');
246
+ return { ...thread, managedSessionId: managed?.id || null };
247
+ });
248
+ return { threads, nextCursor: result.nextCursor || null };
249
+ }
250
+
251
+ async function readCodexThread(pctx, threadId) {
252
+ threadIdentifier(threadId);
253
+ const result = await withHistoryAdapter(pctx, adapter => adapter.peekThread(threadId));
254
+ return { thread: assertProjectThread(pctx, result.thread, threadId) };
255
+ }
256
+
257
+ async function forkCodexThread(pctx, threadId, input = {}) {
258
+ threadIdentifier(threadId);
259
+ if (!input || typeof input !== 'object' || Array.isArray(input) || input.confirmed !== true) {
260
+ throw new CliError('HANDOFF_REQUIRED', 'Explicitly confirm creating a new thread and executor from this history');
261
+ }
262
+ for (const key of Object.keys(input)) {
263
+ if (!['confirmed', 'lastTurnId', 'action_token', 'actionToken', 'epoch', 'authorizeMutation'].includes(key)) throw new CliError('BAD_REQUEST', `Unsupported history fork option: ${key}`);
264
+ }
265
+ if (input.lastTurnId !== undefined) threadIdentifier(input.lastTurnId);
266
+ // Fork on a temporary executor and close it before resume. A live source
267
+ // keeps its process and active turn; the new thread has one managed owner.
268
+ const result = await withHistoryAdapter(pctx, async adapter => {
269
+ const source = await adapter.peekThread(threadId);
270
+ assertProjectThread(pctx, source.thread, threadId);
271
+ const forked = await adapter.forkThread(threadId, input.lastTurnId ? { lastTurnId: input.lastTurnId } : {}, input.authorizeMutation);
272
+ assertProjectThread(pctx, forked.thread);
273
+ if (forked.thread.id === threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Fork did not create a new thread');
274
+ return forked;
275
+ }, true);
276
+ try {
277
+ const session = await startCodexSession({ projectCtx: pctx, kind: 'codex', mode: 'resume',
278
+ resume: result.thread.id, handoffConfirmed: true });
279
+ return { thread: result.thread, session, sourceThreadId: threadId };
280
+ } catch (error) {
281
+ throw new CliError(error.code || 'CODEX_START_FAILED', 'The forked history was saved but its executor could not open; inspect history before retrying',
282
+ { forkedThreadId: result.thread.id });
283
+ }
284
+ }
285
+ return { startCodexSession, codexAction, stopCodexSession,
286
+ listCodexThreads, readCodexThread, forkCodexThread };
287
+ }
@@ -0,0 +1,204 @@
1
+ // Server-side terminal control ownership. Action tokens stay private; clients
2
+ // share only controller IDs and monotonically increasing fencing epochs.
3
+
4
+ export class ControlLeaseError extends Error {
5
+ constructor(code, message, control = null) {
6
+ super(message);
7
+ this.name = 'ControlLeaseError';
8
+ this.code = code;
9
+ this.statusCode = 409;
10
+ this.control = control;
11
+ }
12
+ }
13
+
14
+ export function createControlLease({
15
+ nowMs = Date.now,
16
+ disconnectGraceMs = 15_000,
17
+ broadcast = () => {},
18
+ schedule = setTimeout,
19
+ cancel = clearTimeout
20
+ } = {}) {
21
+ if (!Number.isSafeInteger(disconnectGraceMs) || disconnectGraceMs < 0) {
22
+ throw new TypeError('disconnectGraceMs must be a nonnegative safe integer');
23
+ }
24
+ const states = new WeakMap();
25
+ let nextClientId = 0;
26
+
27
+ function stateFor(session) {
28
+ if (!session || typeof session !== 'object') throw new TypeError('session must be an object');
29
+ let state = states.get(session);
30
+ if (!state) {
31
+ state = { epoch: 0, clients: new Map(), controller: null, expiresAt: null, timer: null };
32
+ states.set(session, state);
33
+ }
34
+ return state;
35
+ }
36
+
37
+ function publicStatus(state) {
38
+ return {
39
+ epoch: state.epoch,
40
+ controller_id: state.controller?.id || null,
41
+ has_controller: Boolean(state.controller),
42
+ controller_connected: Boolean(state.controller?.connected),
43
+ expires_at: state.expiresAt,
44
+ client_count: state.clients.size
45
+ };
46
+ }
47
+
48
+ function notify(session, state) {
49
+ // A closed socket must not undo an ownership change or interrupt fencing.
50
+ try { broadcast(session, { type: 'control', control: publicStatus(state) }); } catch {}
51
+ }
52
+
53
+ function clearExpiry(state) {
54
+ if (state.timer !== null) cancel(state.timer);
55
+ state.timer = null;
56
+ }
57
+
58
+ function advanceEpoch(state) {
59
+ if (state.epoch === Number.MAX_SAFE_INTEGER) {
60
+ throw new ControlLeaseError('CONTROL_EPOCH_EXHAUSTED', 'Terminal control epoch exhausted');
61
+ }
62
+ state.epoch += 1;
63
+ }
64
+
65
+ function expire(session, state) {
66
+ if (!state.controller || state.controller.connected || state.expiresAt > nowMs()) return false;
67
+ clearExpiry(state);
68
+ advanceEpoch(state);
69
+ state.controller = null;
70
+ state.expiresAt = null;
71
+ notify(session, state);
72
+ return true;
73
+ }
74
+
75
+ function scheduleExpiry(session, state) {
76
+ clearExpiry(state);
77
+ const epoch = state.epoch;
78
+ state.timer = schedule(() => {
79
+ state.timer = null;
80
+ if (states.get(session) !== state || state.epoch !== epoch || state.controller?.connected) return;
81
+ if (!expire(session, state) && state.controller) scheduleExpiry(session, state);
82
+ }, Math.max(0, state.expiresAt - nowMs()));
83
+ state.timer?.unref?.();
84
+ }
85
+
86
+ function current(session) {
87
+ const state = stateFor(session);
88
+ expire(session, state);
89
+ return state;
90
+ }
91
+
92
+ function checkToken(token) {
93
+ if (typeof token !== 'string' || !token) {
94
+ throw new ControlLeaseError('UNKNOWN_CONTROL_CLIENT', 'A connected terminal action token is required');
95
+ }
96
+ }
97
+
98
+ function connectedClient(state, token) {
99
+ checkToken(token);
100
+ const client = state.clients.get(token);
101
+ if (!client?.connected) {
102
+ throw new ControlLeaseError('UNKNOWN_CONTROL_CLIENT', 'Terminal client is disconnected', publicStatus(state));
103
+ }
104
+ return client;
105
+ }
106
+
107
+ function checkEpoch(state, epoch) {
108
+ if (!Number.isSafeInteger(epoch) || epoch < 0 || epoch !== state.epoch) {
109
+ throw new ControlLeaseError('STALE_CONTROL_EPOCH', 'Terminal control changed; refresh its control state', publicStatus(state));
110
+ }
111
+ }
112
+
113
+ function setController(state, client) {
114
+ clearExpiry(state);
115
+ advanceEpoch(state);
116
+ state.controller = client;
117
+ state.expiresAt = null;
118
+ }
119
+
120
+ function status(session, actionToken) {
121
+ const state = current(session);
122
+ const result = publicStatus(state);
123
+ if (actionToken !== undefined) {
124
+ const client = state.clients.get(actionToken);
125
+ result.client_id = client?.id || (state.controller?.token === actionToken ? state.controller.id : null);
126
+ result.can_control = Boolean(client?.connected && state.controller === client);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ function connect(session, actionToken) {
132
+ checkToken(actionToken);
133
+ const state = current(session);
134
+ if (state.clients.has(actionToken)) {
135
+ throw new ControlLeaseError('CONTROL_CLIENT_CONNECTED', 'Terminal action token is already connected', publicStatus(state));
136
+ }
137
+ const retained = state.controller?.token === actionToken ? state.controller : null;
138
+ const client = retained || { token: actionToken, id: `client-${++nextClientId}`, connected: true };
139
+ client.connected = true;
140
+ state.clients.set(actionToken, client);
141
+ if (!state.controller || retained) setController(state, client);
142
+ notify(session, state);
143
+ return status(session, actionToken);
144
+ }
145
+
146
+ function disconnect(session, actionToken) {
147
+ const state = current(session);
148
+ const client = state.clients.get(actionToken);
149
+ if (!client) return status(session, actionToken);
150
+ state.clients.delete(actionToken);
151
+ client.connected = false;
152
+ if (state.controller === client) {
153
+ state.expiresAt = nowMs() + disconnectGraceMs;
154
+ if (disconnectGraceMs === 0) expire(session, state);
155
+ else scheduleExpiry(session, state);
156
+ }
157
+ notify(session, state);
158
+ return status(session, actionToken);
159
+ }
160
+
161
+ function claim(session, actionToken, { epoch, takeover = false } = {}) {
162
+ const state = current(session);
163
+ const client = connectedClient(state, actionToken);
164
+ checkEpoch(state, epoch);
165
+ if (state.controller !== client) {
166
+ if (state.controller && takeover !== true) {
167
+ throw new ControlLeaseError('CONTROL_HELD', 'Another client controls this terminal; explicitly take control', publicStatus(state));
168
+ }
169
+ setController(state, client);
170
+ notify(session, state);
171
+ }
172
+ return status(session, actionToken);
173
+ }
174
+
175
+ function assertControl(session, actionToken, epoch) {
176
+ const state = current(session);
177
+ const client = connectedClient(state, actionToken);
178
+ checkEpoch(state, epoch);
179
+ if (state.controller !== client) {
180
+ throw new ControlLeaseError('CONTROL_REQUIRED', 'Take terminal control before changing this session', publicStatus(state));
181
+ }
182
+ return status(session, actionToken);
183
+ }
184
+
185
+ function release(session, actionToken, epoch) {
186
+ assertControl(session, actionToken, epoch);
187
+ const state = states.get(session);
188
+ clearExpiry(state);
189
+ advanceEpoch(state);
190
+ state.controller = null;
191
+ state.expiresAt = null;
192
+ notify(session, state);
193
+ return status(session, actionToken);
194
+ }
195
+
196
+ function forget(session) {
197
+ const state = states.get(session);
198
+ if (!state) return;
199
+ clearExpiry(state);
200
+ states.delete(session);
201
+ }
202
+
203
+ return { connect, disconnect, status, claim, release, assertControl, forget };
204
+ }