@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +32 -0
  3. package/README.zh-CN.md +31 -0
  4. package/docs/guide.md +58 -0
  5. package/docs/guide.zh-CN.md +55 -0
  6. package/lib/cli/app.mjs +2 -0
  7. package/lib/cli/commands/coordination.mjs +3 -1
  8. package/lib/cli/commands/gc.mjs +182 -32
  9. package/lib/cli/commands/misc.mjs +15 -0
  10. package/lib/cli/commands/peer.mjs +21 -5
  11. package/lib/process/identity.mjs +30 -0
  12. package/lib/runtime/buffer-directory-lease.mjs +2 -1
  13. package/lib/runtime/client.mjs +3 -0
  14. package/lib/shared/file-lock.mjs +11 -4
  15. package/lib/shared/socket-lock-worker.mjs +73 -24
  16. package/lib/terminal/tmux.mjs +53 -23
  17. package/lib/web/buffer-gc-runtime.mjs +2 -2
  18. package/lib/web/project-contexts.mjs +6 -1
  19. package/lib/web/runtime-main.mjs +3 -0
  20. package/lib/web/startup.mjs +4 -2
  21. package/package.json +2 -2
  22. package/scripts/regression.mjs +95 -98
  23. package/docs/architecture-split-review.md +0 -217
  24. package/docs/current-state-review-2026-08.md +0 -341
  25. package/docs/defect-review-2026-07.md +0 -382
  26. package/docs/layout-split-stack-audit.md +0 -298
  27. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
  28. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
  29. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
  30. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
  31. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
  32. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
  33. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
  34. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
  35. 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(ctx, 'POST', `/api/sessions/${encodeURIComponent(id)}/stop`, {});
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
@@ -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
  }
@@ -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;
@@ -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
- { timeoutMs = 5000, retryMs = 25, nonblocking = false } = {}
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
- const closing = servers;
34
- servers = [];
35
- if (closing.length === 0) {
36
- callback(null);
40
+ if (closeOperation !== null) {
41
+ closeOperation.callbacks.push(callback);
42
+ if (closeOperation.cleanupStarted) destroyAcceptedSockets();
37
43
  return;
38
44
  }
39
- let remaining = closing.length;
40
- let firstError = null;
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
- if (remaining === 0) callback(firstError);
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(closed);
78
+ server.close(serverClosed);
49
79
  } catch (error) {
50
- closed(error);
80
+ serverClosed(error);
51
81
  }
52
82
  }
83
+ destroyAcceptedSockets();
84
+ operation.cleanupStarted = true;
85
+ complete();
53
86
  }
54
87
 
55
- function closeAndFinish(status, detail = 0) {
88
+ function requestTerminalFailure() {
89
+ if (finished) return;
90
+ terminalFailureRequested = true;
56
91
  generation += 1;
57
- closeServers((error) => finish(error ? states.FAILED : status, error ? 1 : detail));
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) finish(states.FAILED, 1);
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.end(lockBanner(target.identity)));
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
- generation += 1;
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', () => closeAndFinish(states.FAILED, 1));
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
- generation += 1;
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) => finish(error ? states.RELEASE_FAILED : states.RELEASED, error ? 1 : 0));
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
- closeAndFinish(states.FAILED, 1);
248
+ requestTerminalFailure();
200
249
  }
201
250
  }
@@ -36,14 +36,20 @@ export function runTmux(args, opts = {}) {
36
36
  return result.stdout || '';
37
37
  }
38
38
 
39
- function tmuxInstallHint() {
40
- if (process.platform === 'darwin') return 'Install tmux with: brew install tmux';
41
- if (process.platform === 'linux') {
42
- if (fs.existsSync('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
43
- if (fs.existsSync('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
44
- if (fs.existsSync('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
45
- if (fs.existsSync('/etc/fedora-release')) return 'Install tmux with: sudo dnf install -y tmux';
46
- return 'Install tmux with your system package manager.';
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 sudo = typeof process.getuid === 'function' && process.getuid() === 0 ? '' : (commandExists('sudo') ? 'sudo ' : '');
74
- if (process.platform === 'darwin' && commandExists('brew')) {
75
- return runInstallCommand('brew install tmux');
76
- }
77
- if (process.platform === 'linux') {
78
- if (commandExists('apt-get')) {
79
- const update = runInstallCommand(`${sudo}apt-get update`);
80
- if (!update.ok) return update;
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: false, output: 'no supported package manager found' };
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
- bufferDirectory(projectCtx)
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) => tokenMatches(provided, 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
  }
@@ -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
  });
@@ -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() + 15000;
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 15s.` +
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.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": "^1.1.0",
59
+ "node-pty": "1.2.0-beta.15",
60
60
  "ws": "^8.21.0"
61
61
  },
62
62
  "license": "Apache-2.0"