@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.
- package/CHANGELOG.md +149 -0
- package/README.md +36 -0
- package/README.zh-CN.md +35 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/guide.md +58 -0
- package/docs/guide.zh-CN.md +55 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +27 -6
- package/lib/cli/commands/coordination.mjs +3 -1
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/gc.mjs +182 -32
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/misc.mjs +15 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/peer.mjs +21 -5
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +33 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/buffer-directory-lease.mjs +2 -1
- package/lib/runtime/client.mjs +3 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/shared/file-lock.mjs +11 -4
- package/lib/shared/socket-lock-worker.mjs +73 -24
- package/lib/terminal/tmux.mjs +53 -23
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +8 -2
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +101 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +4 -2
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +12 -4
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +338 -187
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
- package/docs/architecture-split-review.md +0 -217
- package/docs/current-state-review-2026-08.md +0 -341
- package/docs/defect-review-2026-07.md +0 -382
- package/docs/layout-split-stack-audit.md +0 -298
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
- 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) =>
|
|
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
|
|
package/lib/web/pty-sessions.mjs
CHANGED
|
@@ -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
|
-
|
|
95
|
+
let db;
|
|
80
96
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
141
|
+
db?.close();
|
|
115
142
|
}
|
|
116
143
|
child.onData((data) => {
|
|
117
144
|
session.buffer += data;
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|