@logicseek/hello-cc 1.0.0 → 1.0.1
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 +77 -0
- package/README.md +32 -0
- package/README.zh-CN.md +31 -0
- package/docs/guide.md +58 -0
- package/docs/guide.zh-CN.md +55 -0
- package/lib/cli/app.mjs +2 -0
- package/lib/cli/commands/coordination.mjs +3 -1
- package/lib/cli/commands/gc.mjs +182 -32
- package/lib/cli/commands/misc.mjs +15 -0
- package/lib/cli/commands/peer.mjs +21 -5
- package/lib/process/identity.mjs +30 -0
- package/lib/runtime/buffer-directory-lease.mjs +2 -1
- package/lib/runtime/client.mjs +3 -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/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/project-contexts.mjs +6 -1
- package/lib/web/runtime-main.mjs +3 -0
- package/lib/web/startup.mjs +4 -2
- package/package.json +2 -2
- package/scripts/regression.mjs +95 -98
- 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
|
@@ -5,6 +5,7 @@ export function createMiscCommands(deps) {
|
|
|
5
5
|
path, process, CliError, parseOpts, printResult,
|
|
6
6
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
7
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
+
waitForProcessIdentityExit,
|
|
8
9
|
PRODUCT_NAME
|
|
9
10
|
} = deps;
|
|
10
11
|
|
|
@@ -35,6 +36,20 @@ export function createMiscCommands(deps) {
|
|
|
35
36
|
printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
39
|
+
if (runtime.source !== 'env') {
|
|
40
|
+
const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
|
|
41
|
+
const stopped = await waitForProcessIdentityExit(processIdentity, {
|
|
42
|
+
timeoutMs: 5_000,
|
|
43
|
+
intervalMs: 25
|
|
44
|
+
});
|
|
45
|
+
if (stopped.state !== 'dead') {
|
|
46
|
+
throw new CliError(
|
|
47
|
+
'RUNTIME_STOP_TIMEOUT',
|
|
48
|
+
`Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
|
|
49
|
+
{ pid: runtime.pid || null, state: stopped.state }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
39
54
|
}
|
|
40
55
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Peer management commands extracted from bin/hcc.mjs.
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
RUNTIME_SESSION_MUTATION_TIMEOUT_MS,
|
|
5
|
+
RUNTIME_SESSION_START_TIMEOUT_MS
|
|
6
|
+
} from '../../runtime/client.mjs';
|
|
7
|
+
|
|
3
8
|
export function createPeerCommands(deps) {
|
|
4
9
|
const {
|
|
5
10
|
connect, now, tx, addEvent, auditPayload, printResult, CliError, parseOpts,
|
|
@@ -83,7 +88,9 @@ async function peerStart(ctx, args) {
|
|
|
83
88
|
throw e;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime
|
|
91
|
+
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime, {
|
|
92
|
+
timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS
|
|
93
|
+
});
|
|
87
94
|
const db = connect(ctx);
|
|
88
95
|
try {
|
|
89
96
|
tx(db, () => {
|
|
@@ -136,7 +143,7 @@ async function peerStart(ctx, args) {
|
|
|
136
143
|
env: childSessionEnv(),
|
|
137
144
|
restartOnEnvChange: Boolean(opts['restart-env']),
|
|
138
145
|
providerForce: Boolean(opts.force)
|
|
139
|
-
}, runtime);
|
|
146
|
+
}, runtime, { timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS });
|
|
140
147
|
printResult(ctx, data.session, (session) =>
|
|
141
148
|
`started ${session.id} (${session.kind}, ${session.role})${session.pane ? ` pane=${session.pane}` : ` pid=${session.pid}`}`);
|
|
142
149
|
}
|
|
@@ -159,7 +166,9 @@ async function peerAttach(ctx, args) {
|
|
|
159
166
|
}
|
|
160
167
|
throw e;
|
|
161
168
|
}
|
|
162
|
-
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime
|
|
169
|
+
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime, {
|
|
170
|
+
timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS
|
|
171
|
+
});
|
|
163
172
|
const db = connect(ctx);
|
|
164
173
|
try {
|
|
165
174
|
addEvent(db, 'peer.attach.requested', actor, null, auditPayload({
|
|
@@ -183,7 +192,7 @@ async function peerAttach(ctx, args) {
|
|
|
183
192
|
pane,
|
|
184
193
|
cwd,
|
|
185
194
|
force: Boolean(opts.force)
|
|
186
|
-
}, runtime);
|
|
195
|
+
}, runtime, { timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS });
|
|
187
196
|
printResult(ctx, data.session, (session) => `attached ${session.id} (${session.kind}, ${session.role}) pane=${session.pane}`);
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -206,7 +215,14 @@ async function peerStop(ctx, args) {
|
|
|
206
215
|
} finally {
|
|
207
216
|
db.close();
|
|
208
217
|
}
|
|
209
|
-
data = await runtimeRequest(
|
|
218
|
+
data = await runtimeRequest(
|
|
219
|
+
ctx,
|
|
220
|
+
'POST',
|
|
221
|
+
`/api/sessions/${encodeURIComponent(id)}/stop`,
|
|
222
|
+
{},
|
|
223
|
+
null,
|
|
224
|
+
{ timeoutMs: RUNTIME_SESSION_MUTATION_TIMEOUT_MS }
|
|
225
|
+
);
|
|
210
226
|
} catch (err) {
|
|
211
227
|
if (err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') {
|
|
212
228
|
// No server: just update the DB to mark the peer as exited
|
package/lib/process/identity.mjs
CHANGED
|
@@ -152,6 +152,36 @@ export async function waitForLiveProcessIdentity(value, options = {}) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export async function waitForProcessIdentityExit(value, options = {}) {
|
|
156
|
+
const inspect = options.inspect || inspectProcessIdentity;
|
|
157
|
+
const monotonicNow = options.monotonicNow || (() => performance.now());
|
|
158
|
+
const wait = options.sleep || ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
|
|
159
|
+
const timeoutMs = Math.max(0, Number(options.timeoutMs ?? 5_000));
|
|
160
|
+
const intervalMs = Math.max(1, Number(options.intervalMs ?? 25));
|
|
161
|
+
const storedIdentity = isCompleteIdentity(value) ? value : null;
|
|
162
|
+
const pid = Number(storedIdentity?.pid ?? value);
|
|
163
|
+
if (!Number.isInteger(pid) || pid <= 0) return { state: 'unknown', identity: null };
|
|
164
|
+
|
|
165
|
+
const deadline = monotonicNow() + timeoutMs;
|
|
166
|
+
let lastObservation = { state: 'unknown', identity: null };
|
|
167
|
+
while (true) {
|
|
168
|
+
const observed = inspect(pid);
|
|
169
|
+
if (observed?.state === 'dead') return { state: 'dead', identity: null };
|
|
170
|
+
if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) {
|
|
171
|
+
if (storedIdentity && compareProcessIdentity(storedIdentity, observed.identity) === 'dead') {
|
|
172
|
+
return { state: 'dead', identity: null };
|
|
173
|
+
}
|
|
174
|
+
lastObservation = observed;
|
|
175
|
+
} else {
|
|
176
|
+
lastObservation = { state: 'unknown', identity: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const remainingMs = deadline - monotonicNow();
|
|
180
|
+
if (remainingMs <= 0) return lastObservation;
|
|
181
|
+
await wait(Math.min(intervalMs, remainingMs));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
155
185
|
export function inspectProcessIdentity(value) {
|
|
156
186
|
let pid;
|
|
157
187
|
try {
|
|
@@ -26,7 +26,8 @@ export function withBufferDirectoryLease(directory, fn, options = {}) {
|
|
|
26
26
|
const lockOptions = {
|
|
27
27
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
28
28
|
...(options.retryMs === undefined ? {} : { retryMs: options.retryMs }),
|
|
29
|
-
...(options.nonblocking === undefined ? {} : { nonblocking: options.nonblocking })
|
|
29
|
+
...(options.nonblocking === undefined ? {} : { nonblocking: options.nonblocking }),
|
|
30
|
+
...(options.createParent === undefined ? {} : { createParent: options.createParent })
|
|
30
31
|
};
|
|
31
32
|
return withFileLock(target, () => fn(path.dirname(target)), lockOptions);
|
|
32
33
|
}
|
package/lib/runtime/client.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
|
|
|
4
4
|
import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
7
|
+
export const RUNTIME_SESSION_START_TIMEOUT_MS = 60_000;
|
|
8
|
+
export const RUNTIME_SESSION_MUTATION_TIMEOUT_MS = 30_000;
|
|
7
9
|
|
|
8
10
|
function requestAbortScope(signal, timeoutMs) {
|
|
9
11
|
const controller = new AbortController();
|
|
@@ -59,6 +61,7 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
59
61
|
method,
|
|
60
62
|
headers,
|
|
61
63
|
body: body === null ? null : JSON.stringify(body),
|
|
64
|
+
timeoutMs: opts.timeoutMs,
|
|
62
65
|
signal: abortScope.signal
|
|
63
66
|
}), abortScope.signal);
|
|
64
67
|
text = res.text;
|
package/lib/shared/file-lock.mjs
CHANGED
|
@@ -78,13 +78,13 @@ function startupTimeoutError(key) {
|
|
|
78
78
|
return error;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function canonicalTarget(value) {
|
|
81
|
+
function canonicalTarget(value, { createParent = true } = {}) {
|
|
82
82
|
if (typeof value !== 'string' || value.length === 0) {
|
|
83
83
|
throw new TypeError('file lock target must be a non-empty path');
|
|
84
84
|
}
|
|
85
85
|
const supplied = path.resolve(value);
|
|
86
86
|
const suppliedParent = path.dirname(supplied);
|
|
87
|
-
fs.mkdirSync(suppliedParent, { recursive: true, mode: 0o700 });
|
|
87
|
+
if (createParent) fs.mkdirSync(suppliedParent, { recursive: true, mode: 0o700 });
|
|
88
88
|
const canonicalParent = fs.realpathSync.native(suppliedParent);
|
|
89
89
|
const parentIdentity = fs.statSync(canonicalParent);
|
|
90
90
|
if (!parentIdentity.isDirectory()) {
|
|
@@ -330,8 +330,12 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
|
|
|
330
330
|
return function withConfiguredFileLock(
|
|
331
331
|
target,
|
|
332
332
|
fn,
|
|
333
|
-
|
|
333
|
+
options = {}
|
|
334
334
|
) {
|
|
335
|
+
const timeoutMs = options.timeoutMs === undefined ? 5000 : options.timeoutMs;
|
|
336
|
+
const retryMs = options.retryMs === undefined ? 25 : options.retryMs;
|
|
337
|
+
const nonblocking = options.nonblocking === undefined ? false : options.nonblocking;
|
|
338
|
+
const createParent = options.createParent === undefined ? true : options.createParent;
|
|
335
339
|
if (typeof fn !== 'function') throw new TypeError('file lock callback must be a function');
|
|
336
340
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
337
341
|
throw new RangeError('file lock timeoutMs must be a non-negative finite number');
|
|
@@ -342,11 +346,14 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
|
|
|
342
346
|
if (typeof nonblocking !== 'boolean') {
|
|
343
347
|
throw new TypeError('file lock nonblocking must be a boolean');
|
|
344
348
|
}
|
|
349
|
+
if (typeof createParent !== 'boolean') {
|
|
350
|
+
throw new TypeError('file lock createParent must be a boolean');
|
|
351
|
+
}
|
|
345
352
|
if (fn.constructor?.name === 'AsyncFunction') {
|
|
346
353
|
throw new TypeError('withFileLock requires a synchronous callback');
|
|
347
354
|
}
|
|
348
355
|
|
|
349
|
-
const location = canonicalTarget(target);
|
|
356
|
+
const location = canonicalTarget(target, { createParent });
|
|
350
357
|
const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking }, workerFactory);
|
|
351
358
|
let result;
|
|
352
359
|
let primary = NO_ERROR;
|
|
@@ -10,9 +10,12 @@ export function runSocketLockWorker(workerData) {
|
|
|
10
10
|
const state = new Int32Array(workerData.stateBuffer);
|
|
11
11
|
const states = workerData.states;
|
|
12
12
|
const targets = workerData.targets;
|
|
13
|
+
const acceptedSockets = new Set();
|
|
13
14
|
let servers = [];
|
|
15
|
+
let closeOperation = null;
|
|
14
16
|
let contentionDeadline = null;
|
|
15
17
|
let releasing = false;
|
|
18
|
+
let terminalFailureRequested = false;
|
|
16
19
|
let finished = false;
|
|
17
20
|
let generation = 0;
|
|
18
21
|
|
|
@@ -24,37 +27,69 @@ export function runSocketLockWorker(workerData) {
|
|
|
24
27
|
|
|
25
28
|
function finish(status, detail = 0) {
|
|
26
29
|
if (finished) return;
|
|
30
|
+
if (terminalFailureRequested) {
|
|
31
|
+
status = states.FAILED;
|
|
32
|
+
detail = 1;
|
|
33
|
+
}
|
|
27
34
|
finished = true;
|
|
28
35
|
publish(status, detail);
|
|
29
36
|
parentPort.close();
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
function closeServers(callback) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
callback(null);
|
|
40
|
+
if (closeOperation !== null) {
|
|
41
|
+
closeOperation.callbacks.push(callback);
|
|
42
|
+
if (closeOperation.cleanupStarted) destroyAcceptedSockets();
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
const closing = servers;
|
|
46
|
+
servers = [];
|
|
47
|
+
const operation = {
|
|
48
|
+
callbacks: [callback],
|
|
49
|
+
cleanupStarted: false,
|
|
50
|
+
closing,
|
|
51
|
+
completed: false,
|
|
52
|
+
firstError: null,
|
|
53
|
+
remaining: closing.length
|
|
54
|
+
};
|
|
55
|
+
closeOperation = operation;
|
|
56
|
+
const complete = () => {
|
|
57
|
+
if (operation.completed || !operation.cleanupStarted || operation.remaining !== 0) return;
|
|
58
|
+
operation.completed = true;
|
|
59
|
+
closeOperation = null;
|
|
60
|
+
const callbacks = operation.callbacks;
|
|
61
|
+
operation.callbacks = [];
|
|
62
|
+
operation.closing = [];
|
|
63
|
+
for (const waiting of callbacks) waiting(operation.firstError);
|
|
64
|
+
};
|
|
41
65
|
const closed = (error = null) => {
|
|
42
|
-
if (error && !firstError) firstError = error;
|
|
43
|
-
remaining -= 1;
|
|
44
|
-
|
|
66
|
+
if (error && !operation.firstError) operation.firstError = error;
|
|
67
|
+
operation.remaining -= 1;
|
|
68
|
+
complete();
|
|
45
69
|
};
|
|
46
|
-
for (const server of closing) {
|
|
70
|
+
for (const server of operation.closing) {
|
|
71
|
+
let settled = false;
|
|
72
|
+
const serverClosed = (error = null) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
closed(error);
|
|
76
|
+
};
|
|
47
77
|
try {
|
|
48
|
-
server.close(
|
|
78
|
+
server.close(serverClosed);
|
|
49
79
|
} catch (error) {
|
|
50
|
-
|
|
80
|
+
serverClosed(error);
|
|
51
81
|
}
|
|
52
82
|
}
|
|
83
|
+
destroyAcceptedSockets();
|
|
84
|
+
operation.cleanupStarted = true;
|
|
85
|
+
complete();
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
function
|
|
88
|
+
function requestTerminalFailure() {
|
|
89
|
+
if (finished) return;
|
|
90
|
+
terminalFailureRequested = true;
|
|
56
91
|
generation += 1;
|
|
57
|
-
closeServers((
|
|
92
|
+
closeServers(() => finish(states.FAILED, 1));
|
|
58
93
|
}
|
|
59
94
|
|
|
60
95
|
function retryAfterContention() {
|
|
@@ -77,8 +112,8 @@ export function runSocketLockWorker(workerData) {
|
|
|
77
112
|
if (finished || attemptGeneration !== generation) return;
|
|
78
113
|
generation += 1;
|
|
79
114
|
closeServers((error) => {
|
|
80
|
-
if (error)
|
|
81
|
-
else retryAfterContention();
|
|
115
|
+
if (error) requestTerminalFailure();
|
|
116
|
+
else if (!terminalFailureRequested) retryAfterContention();
|
|
82
117
|
});
|
|
83
118
|
}
|
|
84
119
|
|
|
@@ -86,6 +121,19 @@ export function runSocketLockWorker(workerData) {
|
|
|
86
121
|
return `${PROTOCOL_PREFIX}${identity}\n`;
|
|
87
122
|
}
|
|
88
123
|
|
|
124
|
+
function serveLock(socket, identity) {
|
|
125
|
+
acceptedSockets.add(socket);
|
|
126
|
+
socket.once('close', () => acceptedSockets.delete(socket));
|
|
127
|
+
socket.on('error', () => {});
|
|
128
|
+
socket.end(lockBanner(identity));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function destroyAcceptedSockets() {
|
|
132
|
+
for (const socket of acceptedSockets) {
|
|
133
|
+
try { socket.destroy(); } catch {}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
89
137
|
function probeOccupant(port, identity, callback) {
|
|
90
138
|
const expected = lockBanner(identity);
|
|
91
139
|
let received = '';
|
|
@@ -129,12 +177,11 @@ export function runSocketLockWorker(workerData) {
|
|
|
129
177
|
const port = target.ports[candidateIndex];
|
|
130
178
|
let candidate;
|
|
131
179
|
try {
|
|
132
|
-
candidate = net.createServer((socket) => socket
|
|
180
|
+
candidate = net.createServer((socket) => serveLock(socket, target.identity));
|
|
133
181
|
const bindError = (error) => {
|
|
134
182
|
if (finished || attemptGeneration !== generation) return;
|
|
135
183
|
if (error?.code !== 'EADDRINUSE') {
|
|
136
|
-
|
|
137
|
-
closeServers(() => finish(states.FAILED, 1));
|
|
184
|
+
requestTerminalFailure();
|
|
138
185
|
return;
|
|
139
186
|
}
|
|
140
187
|
probeOccupant(port, target.identity, (matching) => {
|
|
@@ -150,13 +197,12 @@ export function runSocketLockWorker(workerData) {
|
|
|
150
197
|
return;
|
|
151
198
|
}
|
|
152
199
|
candidate.off('error', bindError);
|
|
153
|
-
candidate.on('error',
|
|
200
|
+
candidate.on('error', requestTerminalFailure);
|
|
154
201
|
servers.push(candidate);
|
|
155
202
|
bindCandidate(targetIndex, candidateIndex + 1, boundForTarget + 1, attemptGeneration);
|
|
156
203
|
});
|
|
157
204
|
} catch {
|
|
158
|
-
|
|
159
|
-
closeServers(() => finish(states.FAILED, 1));
|
|
205
|
+
requestTerminalFailure();
|
|
160
206
|
}
|
|
161
207
|
}
|
|
162
208
|
|
|
@@ -184,7 +230,10 @@ export function runSocketLockWorker(workerData) {
|
|
|
184
230
|
Atomics.load(state, STATE_INDEX) !== states.ACQUIRED) return;
|
|
185
231
|
releasing = true;
|
|
186
232
|
generation += 1;
|
|
187
|
-
closeServers((error) =>
|
|
233
|
+
closeServers((error) => {
|
|
234
|
+
if (terminalFailureRequested) finish(states.FAILED, 1);
|
|
235
|
+
else finish(error ? states.RELEASE_FAILED : states.RELEASED, error ? 1 : 0);
|
|
236
|
+
});
|
|
188
237
|
});
|
|
189
238
|
|
|
190
239
|
try {
|
|
@@ -196,6 +245,6 @@ export function runSocketLockWorker(workerData) {
|
|
|
196
245
|
}
|
|
197
246
|
attemptAll();
|
|
198
247
|
} catch {
|
|
199
|
-
|
|
248
|
+
requestTerminalFailure();
|
|
200
249
|
}
|
|
201
250
|
}
|
package/lib/terminal/tmux.mjs
CHANGED
|
@@ -36,14 +36,20 @@ export function runTmux(args, opts = {}) {
|
|
|
36
36
|
return result.stdout || '';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function tmuxInstallHint(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
return 'Install tmux with
|
|
39
|
+
export function tmuxInstallHint({
|
|
40
|
+
platform = process.platform,
|
|
41
|
+
pathExists = fs.existsSync
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (platform === 'darwin') return 'Install tmux with: brew install tmux';
|
|
44
|
+
if (platform === 'linux') {
|
|
45
|
+
if (pathExists('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
|
|
46
|
+
if (pathExists('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
|
|
47
|
+
if (pathExists('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
|
|
48
|
+
if (pathExists('/etc/fedora-release') || pathExists('/etc/redhat-release')) {
|
|
49
|
+
return 'Install tmux with: sudo dnf install -y tmux (or sudo yum install -y tmux on older systems)';
|
|
50
|
+
}
|
|
51
|
+
if (pathExists('/etc/SuSE-release')) return 'Install tmux with: sudo zypper --non-interactive install tmux';
|
|
52
|
+
return 'Install tmux with your Linux system package manager (apt/apt-get, dnf/yum, apk, pacman, or zypper).';
|
|
47
53
|
}
|
|
48
54
|
return 'Install tmux and make sure it is on PATH.';
|
|
49
55
|
}
|
|
@@ -54,6 +60,36 @@ function commandExists(name) {
|
|
|
54
60
|
}).status === 0;
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
export function tmuxInstallPlan({
|
|
64
|
+
platform = process.platform,
|
|
65
|
+
commandAvailable = commandExists,
|
|
66
|
+
isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (platform === 'darwin') {
|
|
69
|
+
return commandAvailable('brew')
|
|
70
|
+
? { manager: 'brew', commands: ['brew install tmux'] }
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
if (platform !== 'linux') return null;
|
|
74
|
+
|
|
75
|
+
if (!isRoot && !commandAvailable('sudo')) return null;
|
|
76
|
+
const sudo = isRoot ? '' : 'sudo ';
|
|
77
|
+
if (commandAvailable('apt-get')) {
|
|
78
|
+
return {
|
|
79
|
+
manager: 'apt-get',
|
|
80
|
+
commands: [`${sudo}apt-get update`, `${sudo}apt-get install -y tmux`]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (commandAvailable('dnf')) return { manager: 'dnf', commands: [`${sudo}dnf install -y tmux`] };
|
|
84
|
+
if (commandAvailable('yum')) return { manager: 'yum', commands: [`${sudo}yum install -y tmux`] };
|
|
85
|
+
if (commandAvailable('apk')) return { manager: 'apk', commands: [`${sudo}apk add tmux`] };
|
|
86
|
+
if (commandAvailable('pacman')) return { manager: 'pacman', commands: [`${sudo}pacman -S --noconfirm tmux`] };
|
|
87
|
+
if (commandAvailable('zypper')) {
|
|
88
|
+
return { manager: 'zypper', commands: [`${sudo}zypper --non-interactive install tmux`] };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
function runInstallCommand(command) {
|
|
58
94
|
const result = spawnSync('sh', ['-lc', command], {
|
|
59
95
|
encoding: 'utf8',
|
|
@@ -70,22 +106,16 @@ export function tryInstallTmux() {
|
|
|
70
106
|
if (process.env.HCC_NO_AUTO_INSTALL_TMUX === '1') {
|
|
71
107
|
return { ok: false, output: 'automatic tmux installation disabled by HCC_NO_AUTO_INSTALL_TMUX=1' };
|
|
72
108
|
}
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return runInstallCommand(`${sudo}apt-get install -y tmux`);
|
|
82
|
-
}
|
|
83
|
-
if (commandExists('dnf')) return runInstallCommand(`${sudo}dnf install -y tmux`);
|
|
84
|
-
if (commandExists('yum')) return runInstallCommand(`${sudo}yum install -y tmux`);
|
|
85
|
-
if (commandExists('apk')) return runInstallCommand(`${sudo}apk add tmux`);
|
|
86
|
-
if (commandExists('pacman')) return runInstallCommand(`${sudo}pacman -S --noconfirm tmux`);
|
|
109
|
+
const plan = tmuxInstallPlan();
|
|
110
|
+
if (!plan) return { ok: false, output: 'no supported package manager with sufficient privileges found' };
|
|
111
|
+
|
|
112
|
+
const output = [];
|
|
113
|
+
for (const command of plan.commands) {
|
|
114
|
+
const result = runInstallCommand(command);
|
|
115
|
+
if (result.output) output.push(result.output);
|
|
116
|
+
if (!result.ok) return { ok: false, output: output.join('\n') };
|
|
87
117
|
}
|
|
88
|
-
return { ok:
|
|
118
|
+
return { ok: true, output: output.join('\n') };
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
export function ensureTmuxAvailable({ autoInstall = true } = {}) {
|
|
@@ -147,8 +147,8 @@ export function createBufferGcRuntime(deps) {
|
|
|
147
147
|
olderThanDays: 14,
|
|
148
148
|
dryRun: false,
|
|
149
149
|
scope: 'auto',
|
|
150
|
-
collectBufferEvidenceNow: () => collectRuntimeBufferEvidence([
|
|
151
|
-
|
|
150
|
+
collectBufferEvidenceNow: (directory) => collectRuntimeBufferEvidence([
|
|
151
|
+
directory
|
|
152
152
|
])
|
|
153
153
|
});
|
|
154
154
|
const appliedDatabaseRows = result.expired_locks + result.old_events;
|
|
@@ -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';
|
|
@@ -145,7 +146,11 @@ 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
|
}
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -499,7 +499,9 @@ const {
|
|
|
499
499
|
return;
|
|
500
500
|
}
|
|
501
501
|
session.actionTokens ||= new Set();
|
|
502
|
+
session.actionTokenSockets ||= new Map();
|
|
502
503
|
session.actionTokens.add(connectionActionToken);
|
|
504
|
+
session.actionTokenSockets.set(connectionActionToken, ws);
|
|
503
505
|
ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session), action_token: connectionActionToken }));
|
|
504
506
|
ws.on('message', (raw) => {
|
|
505
507
|
try {
|
|
@@ -523,6 +525,7 @@ const {
|
|
|
523
525
|
ws.on('close', () => {
|
|
524
526
|
session.clients.delete(ws);
|
|
525
527
|
session.actionTokens.delete(connectionActionToken);
|
|
528
|
+
session.actionTokenSockets.delete(connectionActionToken);
|
|
526
529
|
if (cookieAuth) cookieAuth.session.sockets.delete(ws);
|
|
527
530
|
});
|
|
528
531
|
});
|
package/lib/web/startup.mjs
CHANGED
|
@@ -31,6 +31,8 @@ import { WEB_CHILD_ENV } from '../core/sessions/launch.mjs';
|
|
|
31
31
|
import { API_VERSION } from '../web/api-version.mjs';
|
|
32
32
|
import { ensureTmuxAvailable } from '../tmux.mjs';
|
|
33
33
|
|
|
34
|
+
export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
|
|
35
|
+
|
|
34
36
|
export function createWebStartup(deps) {
|
|
35
37
|
const {
|
|
36
38
|
splitProcessArgs, sameResolvedPath,
|
|
@@ -257,7 +259,7 @@ async function waitForStartedRuntime(ctx, child, logFile) {
|
|
|
257
259
|
exitInfo = { code, signal };
|
|
258
260
|
});
|
|
259
261
|
|
|
260
|
-
const deadline = Date.now() +
|
|
262
|
+
const deadline = Date.now() + WEB_RUNTIME_START_TIMEOUT_MS;
|
|
261
263
|
while (Date.now() < deadline) {
|
|
262
264
|
const runtime = await readHealthyGlobalRuntime();
|
|
263
265
|
if (runtime) return runtime;
|
|
@@ -278,7 +280,7 @@ async function waitForStartedRuntime(ctx, child, logFile) {
|
|
|
278
280
|
} catch {}
|
|
279
281
|
const detail = redactedLogText(tailFile(logFile));
|
|
280
282
|
throw new CliError('RUNTIME_START_TIMEOUT',
|
|
281
|
-
`${PRODUCT_NAME} runtime did not become healthy within
|
|
283
|
+
`${PRODUCT_NAME} runtime did not become healthy within ${WEB_RUNTIME_START_TIMEOUT_MS / 1000}s.` +
|
|
282
284
|
`${detail ? `\n\nLast log lines:\n${detail}` : ''}`,
|
|
283
285
|
{ log: logFile });
|
|
284
286
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logicseek/hello-cc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@xterm/addon-fit": "^0.11.0",
|
|
58
58
|
"@xterm/xterm": "^6.0.0",
|
|
59
|
-
"node-pty": "
|
|
59
|
+
"node-pty": "1.2.0-beta.15",
|
|
60
60
|
"ws": "^8.21.0"
|
|
61
61
|
},
|
|
62
62
|
"license": "Apache-2.0"
|