@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.
- package/CHANGELOG.md +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -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/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 +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- 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/native.mjs +130 -0
- 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 +3 -1
- package/lib/release/release-notes.mjs +9 -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/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -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 +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- 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 +11 -3
- 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 +243 -89
- 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
|
@@ -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
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|