@logicseek/hello-cc 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { CliError } from '../shared/errors.mjs';
4
5
  import { redactSecrets } from '../shared/redact.mjs';
5
6
  import { tx } from '../db/schema.mjs';
@@ -29,10 +30,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
29
30
  return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
30
31
  message: error.message || 'Native runtime is disconnected' });
31
32
  }
33
+ function publishState(session, state) {
34
+ // Event cursors do not cover approvals, delivery receipts or capabilities.
35
+ // Compare the complete normalized state; validation and lease revocation
36
+ // still run on every read, including reads that need no new UI frame.
37
+ const changed = !isDeepStrictEqual(session.nativeState, state);
38
+ session.nativeState = state;
39
+ if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
40
+ }
32
41
  function disconnect(session, error, retire = false) {
33
42
  session.status = 'disconnected';
34
- session.nativeState = { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) };
35
- broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
43
+ publishState(session, { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) });
36
44
  if (retire && !session.nativeRetired) {
37
45
  session.nativeRetired = true;
38
46
  closeSessionClients(session);
@@ -100,7 +108,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
100
108
  const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
101
109
  session.binding = binding;
102
110
  session.cwd = value.cwd;
103
- session.nativeState = redactSecrets({ backend: 'native', connected: true, root: value.root,
111
+ const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
104
112
  peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
105
113
  executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
106
114
  status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
@@ -111,7 +119,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
111
119
  session.status = CLOSED.has(value.snapshot.status) ? 'exited'
112
120
  : value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
113
121
  if (session.status === 'exited') session.exitedAt = now();
114
- broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
122
+ publishState(session, state);
115
123
  return session.nativeSnapshot();
116
124
  }
117
125
  function makeSession(ctx, checked) {
@@ -261,8 +269,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
261
269
  if (action === 'respond') { await readState(session); current(session); }
262
270
  if (action === 'close') {
263
271
  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() });
272
+ publishState(session, { ...session.nativeState, connected: false, status: 'closed', closing: false });
266
273
  closeSessionClients(session);
267
274
  session.actionTokens.clear();
268
275
  }
@@ -3,10 +3,12 @@ import process from 'node:process';
3
3
  import { intOpt, positiveSafeIntOpt, required } from '../cli-args.mjs';
4
4
  import { CliError } from '../shared/errors.mjs';
5
5
  import {
6
+ findRequestedLock,
6
7
  lockLabel,
7
8
  lockScope,
8
9
  locksConflict,
9
- scopedLockResource
10
+ scopedLockResource,
11
+ upsertRequestedLock
10
12
  } from '../core/coordination/locks.mjs';
11
13
  import { normalizeStateResources } from '../ui/state-render.mjs';
12
14
  import {
@@ -103,23 +105,31 @@ export function createWebPeerActions(deps = {}) {
103
105
  function webPeerRegister(projectCtx, peer, input = {}) {
104
106
  webMutationPeer(peer, input);
105
107
  const db = connect(projectCtx);
106
- const row = {
107
- id: peer,
108
- kind: input.kind || 'other',
109
- role: input.role || 'peer',
110
- worktree: path.resolve(input.worktree || projectCtx.cwd),
111
- branch: input.branch || detectBranch(projectCtx.cwd),
112
- pid: intOpt(input, 'pid', process.ppid),
113
- status: input.status || 'idle',
114
- capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
115
- };
116
108
  try {
109
+ const existing = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer) || null;
110
+ const explicitPid = Object.hasOwn(input, 'pid');
111
+ const row = {
112
+ id: peer,
113
+ kind: input.kind || existing?.kind || 'other',
114
+ role: input.role || existing?.role || 'peer',
115
+ worktree: path.resolve(input.worktree || existing?.worktree || projectCtx.cwd),
116
+ branch: input.branch || existing?.branch || detectBranch(projectCtx.cwd),
117
+ pid: explicitPid ? intOpt(input, 'pid') : (existing?.pid ?? null),
118
+ ...(!explicitPid ? { processIdentity: existing ? {
119
+ pid: existing.pid,
120
+ startToken: existing.pid_start_token,
121
+ commandHash: existing.pid_command_hash
122
+ } : null } : {}),
123
+ status: input.status || existing?.status || 'idle',
124
+ capabilities: Array.isArray(input.cap) ? input.cap.join(',') :
125
+ (input.cap || input.capabilities || existing?.capabilities || 'web')
126
+ };
117
127
  upsertPeer(db, row);
118
128
  addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
129
+ return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
119
130
  } finally {
120
131
  db.close();
121
132
  }
122
- return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
123
133
  }
124
134
 
125
135
  function webPeerHeartbeat(projectCtx, peer, input = {}) {
@@ -294,21 +304,11 @@ export function createWebPeerActions(deps = {}) {
294
304
  }
295
305
  const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
296
306
  const expiresAt = leaseDeadline(t, ttl);
297
- db.prepare(`
298
- INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
299
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
300
- ON CONFLICT(resource) DO UPDATE SET
301
- base_resource = excluded.base_resource,
302
- scope = excluded.scope,
303
- owner = excluded.owner,
304
- task_id = excluded.task_id,
305
- reason = excluded.reason,
306
- expires_at = excluded.expires_at,
307
- created_at = excluded.created_at,
308
- ttl_sec = excluded.ttl_sec
309
- `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
307
+ const acquired = upsertRequestedLock(db, requested, {
308
+ owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
309
+ });
310
310
  addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
311
- return { lock: db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) };
311
+ return { lock: acquired };
312
312
  }
313
313
  });
314
314
  if (outcome.error) throw outcome.error;
@@ -327,7 +327,7 @@ export function createWebPeerActions(deps = {}) {
327
327
  try {
328
328
  touchPeer(db, peer, null);
329
329
  const result = tx(db, () => {
330
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
330
+ const existing = findRequestedLock(db, requested);
331
331
  if (!existing) return { released: false, ...requested };
332
332
  if (existing.owner !== peer) {
333
333
  throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
@@ -97,7 +97,8 @@ import {
97
97
  requestOriginMatches,
98
98
  sendFile,
99
99
  sendJson,
100
- tokenMatches
100
+ tokenMatches,
101
+ tokenlessRequestAllowed
101
102
  } from '../web/http.mjs';
102
103
  import {
103
104
  API_VERSION,
@@ -488,14 +489,30 @@ const {
488
489
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
489
490
  });
490
491
 
492
+ const dispatchHttp = (req, res) => {
493
+ void handleWebRequest(req, res).catch((error) => {
494
+ try {
495
+ console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
496
+ if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
497
+ else res.destroy();
498
+ } catch {
499
+ try { res.destroy(); } catch {}
500
+ }
501
+ });
502
+ };
491
503
  const server = useTls
492
- ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, handleWebRequest)
493
- : http.createServer(handleWebRequest);
504
+ ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
505
+ : http.createServer(dispatchHttp);
494
506
 
495
507
  const wss = new WebSocketServer({ noServer: true });
496
508
  server.on('upgrade', (req, socket, head) => {
497
509
  try {
498
510
  const url = requestUrl(req);
511
+ if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
512
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
513
+ socket.destroy();
514
+ return;
515
+ }
499
516
  if (!readWebSocketApiVersion(url).ok) {
500
517
  const body = JSON.stringify(apiVersionUnsupportedBody());
501
518
  socket.end([
@@ -14,8 +14,12 @@ export function runtimeConnectHost(host) {
14
14
  return host;
15
15
  }
16
16
 
17
+ function urlHost(host) {
18
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
19
+ }
20
+
17
21
  export function runtimeBaseUrl(host, port, tls = false) {
18
- return `${tls ? 'https' : 'http'}://${runtimeConnectHost(host)}:${port}`;
22
+ return `${tls ? 'https' : 'http'}://${urlHost(runtimeConnectHost(host))}:${port}`;
19
23
  }
20
24
 
21
25
  export function runtimeApiUrl(runtime, route) {
@@ -24,7 +28,17 @@ export function runtimeApiUrl(runtime, route) {
24
28
  }
25
29
 
26
30
  export function requestUrl(req) {
27
- return new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
31
+ try {
32
+ const base = new URL(`http://${req.headers.host || 'localhost'}`);
33
+ if (base.username || base.password || base.pathname !== '/' || base.search || base.hash) {
34
+ throw new Error('Invalid Host authority');
35
+ }
36
+ const url = new URL(req.url || '/', base);
37
+ if (url.origin !== base.origin) throw new Error('Request target changes authority');
38
+ return url;
39
+ } catch {
40
+ throw new CliError('BAD_REQUEST', 'Invalid request URL or Host header');
41
+ }
28
42
  }
29
43
 
30
44
  export function isLoopbackHost(host) {
@@ -84,13 +98,13 @@ export function publicRuntimeUrl(runtime, projectRoot = null) {
84
98
  ? '<machine-ip>'
85
99
  : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
86
100
  const scheme = runtime.tls ? 'https' : 'http';
87
- return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
101
+ return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
88
102
  }
89
103
 
90
104
  export function localRuntimeUrl(runtime, projectRoot = null) {
91
105
  const host = runtimeConnectHost(runtime.host || '127.0.0.1');
92
106
  const scheme = runtime.tls ? 'https' : 'http';
93
- return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
107
+ return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
94
108
  }
95
109
 
96
110
  // Issue a request to a runtime API route. Returns { ok, status, text }.
@@ -105,7 +105,7 @@ export function createSessionSerialize({
105
105
  : session.type === 'native' ? ['chat', 'delivery-receipts', 'results',
106
106
  ...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : [])] : ['terminal', 'results'],
107
107
  local_clients: session.type === 'tmux' ? localClientObservation(session) : null,
108
- executor_id: session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
108
+ executor_id: session.executorId || session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
109
109
  native_connected: session.type === 'native' ? Boolean(session.nativeState?.connected) : null,
110
110
  created_at: session.createdAt,
111
111
  exited_at: session.exitedAt || null,
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import process from 'node:process';
8
+ import { createHash, randomUUID } from 'node:crypto';
8
9
  import { URL } from 'node:url';
9
10
  import { spawn, spawnSync } from 'node:child_process';
10
11
  import { setTimeout as sleep } from 'node:timers/promises';
@@ -13,10 +14,13 @@ import { redactCliArgs } from '../shared/redact.mjs';
13
14
  import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mjs';
14
15
  import { printResult } from '../format.mjs';
15
16
  import { commandPath, tailFile } from '../cli-runtime.mjs';
17
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
16
18
  import { registerProject } from '../runtime/projects.mjs';
17
- import { runtimePath, globalRuntimePath, webLogPath } from '../runtime/paths.mjs';
19
+ import { runtimePath, globalRuntimePath, globalStateDir, webLogPath } from '../runtime/paths.mjs';
18
20
  import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
19
21
  import { runtimeRequest } from '../runtime/client.mjs';
22
+ import { withFileLock } from '../shared/file-lock.mjs';
23
+ import { writeJsonSafe } from '../shared/json-file.mjs';
20
24
  import {
21
25
  expectedWebHost,
22
26
  isLoopbackHost,
@@ -32,6 +36,56 @@ import { API_VERSION } from '../web/api-version.mjs';
32
36
  import { ensureTmuxAvailable } from '../tmux.mjs';
33
37
 
34
38
  export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
39
+ const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
40
+
41
+ function webStartOwnerAlive(owner) {
42
+ const pid = owner?.pid;
43
+ if (!Number.isInteger(pid) || pid <= 0) return true;
44
+ const observed = inspectProcessIdentity(pid);
45
+ if (observed.state === 'dead') return false;
46
+ if (owner.process_identity && observed.state === 'live') {
47
+ return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
48
+ }
49
+ return true;
50
+ }
51
+
52
+ async function withWebStartLock(fn) {
53
+ const lockFile = path.join(globalStateDir(), 'web-start.lock');
54
+ const owner = {
55
+ pid: process.pid,
56
+ process_identity: inspectProcessIdentity(process.pid).identity,
57
+ nonce: randomUUID()
58
+ };
59
+ const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
60
+ while (true) {
61
+ const acquired = withFileLock(lockFile, (lockedFile) => {
62
+ if (fs.existsSync(lockedFile)) {
63
+ let previous = null;
64
+ try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
65
+ if (!previous || webStartOwnerAlive(previous)) return false;
66
+ }
67
+ writeJsonSafe(lockedFile, owner, { mode: 0o600 });
68
+ return true;
69
+ });
70
+ if (acquired) break;
71
+ if (performance.now() >= deadline) {
72
+ throw new CliError('WEB_START_LOCK_TIMEOUT',
73
+ 'Timed out waiting for another web startup to finish.');
74
+ }
75
+ await sleep(100);
76
+ }
77
+ try {
78
+ return await fn();
79
+ } finally {
80
+ withFileLock(lockFile, (lockedFile) => {
81
+ let current = null;
82
+ try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
83
+ if (current?.nonce === owner.nonce && current.pid === owner.pid) {
84
+ fs.rmSync(lockedFile, { force: true });
85
+ }
86
+ });
87
+ }
88
+ }
35
89
 
36
90
  export function createWebStartup(deps) {
37
91
  const {
@@ -84,25 +138,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
84
138
  const ppid = Number.parseInt(match[2], 10);
85
139
  if (!Number.isFinite(pid)) continue;
86
140
  if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
87
- rows.push({ pid, args: match[3] });
141
+ rows.push({ pid, ppid, args: match[3] });
88
142
  }
89
143
 
90
144
  const ancestorPids = currentProcessAncestorPids(ppidByPid);
91
- const pids = [];
145
+ const candidates = [];
92
146
  for (const row of rows) {
147
+ // A live parent still owns its web command. In particular, another CLI
148
+ // invocation waiting for the startup lock is not an orphan runtime.
149
+ if (row.ppid !== 1) continue;
93
150
  if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
94
- if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
151
+ if (!hccWebProcessMatches(row.args, ctx)) continue;
152
+ const observed = inspectProcessIdentity(row.pid);
153
+ if (observed.state === 'live' &&
154
+ observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
155
+ candidates.push(observed.identity);
156
+ }
95
157
  }
96
- if (!pids.length) return;
158
+ if (!candidates.length) return;
159
+
160
+ const stillSameProcess = (identity) => {
161
+ const observed = inspectProcessIdentity(identity.pid);
162
+ return observed.state === 'live' &&
163
+ compareProcessIdentity(identity, observed.identity) === 'live' &&
164
+ identity.commandHash === observed.identity.commandHash;
165
+ };
97
166
 
98
- for (const pid of pids) {
99
- try { process.kill(pid, 'SIGTERM'); } catch {}
167
+ for (const identity of candidates) {
168
+ if (!stillSameProcess(identity)) continue;
169
+ try { process.kill(identity.pid, 'SIGTERM'); } catch {}
100
170
  }
101
171
  await sleep(250);
102
- for (const pid of pids) {
172
+ for (const identity of candidates) {
173
+ if (!stillSameProcess(identity)) continue;
103
174
  try {
104
- process.kill(pid, 0);
105
- process.kill(pid, 'SIGKILL');
175
+ process.kill(identity.pid, 'SIGKILL');
106
176
  } catch {}
107
177
  }
108
178
  }
@@ -160,6 +230,10 @@ async function startWebBackground(ctx, args) {
160
230
  assertWebTokenForHost(requestedHost, !opts['no-token']);
161
231
  if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
162
232
  ensureTmuxAvailable({ autoInstall: true });
233
+ return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
234
+ }
235
+
236
+ async function startWebBackgroundLocked(ctx, args, opts) {
163
237
  const setup = await prepareLocalBus(ctx, {
164
238
  ...opts,
165
239
  installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { selectCurrentTask } from '../core/coordination/automation.mjs';
3
3
  import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
4
4
  import { CliError } from '../shared/errors.mjs';
5
+ import { redactSecrets } from '../shared/redact.mjs';
5
6
 
6
7
  export function createTaskResults({ connectWebProject, resolveSessionPeerId, now, addEvent }) {
7
8
  const turnScopes = new WeakMap();
@@ -31,7 +32,7 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
31
32
  } finally { db.close(); }
32
33
  }
33
34
  function observeCodexResults(session, state) {
34
- if (!session.binding?.provider_session_id || state.executorId !== session.adapter?.snapshot().executorId) return;
35
+ if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
35
36
  const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
36
37
  if (!thread?.turns?.length) return;
37
38
  const db = connectWebProject(session.ctx);
@@ -54,14 +55,16 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
54
55
  const exitCode = item.exitCode;
55
56
  const status = exitCode === 0 && item.status === 'completed' ? 'passed'
56
57
  : item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
58
+ const command = redactSecrets(String(item.command || ''));
59
+ const output = redactSecrets(String(item.aggregatedOutput || ''));
57
60
  recordTaskResult(db, { ...common, itemId: item.id, kind: 'command', status, exitCode,
58
61
  resultKey: `${state.executorId}:${turn.id}:${item.id}:command`,
59
- title: String(item.command || 'Command execution').slice(0, 300),
60
- command: String(item.command || '').slice(0, 64000),
61
- details: String(item.aggregatedOutput || '').slice(0, 64000), evidence: [] }, { now, addEvent });
62
+ title: (command || 'Command execution').slice(0, 300),
63
+ command: command.slice(0, 64000),
64
+ details: output.slice(0, 64000), evidence: [] }, { now, addEvent });
62
65
  }
63
66
  if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
64
- const diff = String(turn.diff).slice(0, 64000);
67
+ const diff = redactSecrets(String(turn.diff)).slice(0, 64000);
65
68
  recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
66
69
  resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
67
70
  }
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const packages = Object.freeze({
6
+ 'xterm.js': '@xterm/xterm',
7
+ 'xterm.css': '@xterm/xterm',
8
+ 'addon-fit.js': '@xterm/addon-fit',
9
+ 'addon-search.js': '@xterm/addon-search'
10
+ });
11
+
12
+ // npm may place dependencies above the HCC package directory. Resolve from
13
+ // this module's installation rather than assuming a nested node_modules.
14
+ export function terminalAssetPath(name) {
15
+ if (!Object.hasOwn(packages, name)) throw new TypeError('Unknown terminal asset');
16
+ const entry = require.resolve(packages[name]);
17
+ return name === 'xterm.css' ? path.resolve(path.dirname(entry), '..', 'css', name) : entry;
18
+ }
@@ -7,12 +7,12 @@ import path from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
9
9
  import { CliError } from '../shared/errors.mjs';
10
+ import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
10
11
 
11
12
  export function createTmuxStream({
12
13
  broadcast,
13
14
  now,
14
15
  refreshPeerIoHeartbeat,
15
- bufferDirectory,
16
16
  withBufferDirectoryLease,
17
17
  shellQuoteArg,
18
18
  ctx
@@ -69,8 +69,7 @@ export function createTmuxStream({
69
69
  function startTmuxStream(session) {
70
70
  const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
71
71
  const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
72
- const streamDirectory = bufferDirectory(session.ctx || ctx);
73
- fs.mkdirSync(streamDirectory, { recursive: true });
72
+ const streamDirectory = ensurePrivateProjectBufferDirectory((session.ctx || ctx).root);
74
73
  const pipeFile = path.join(streamDirectory, `tmux-${safePane}-${safeId}.pipe`);
75
74
  session.pipeFile = pipeFile;
76
75
  try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
@@ -89,7 +88,7 @@ export function createTmuxStream({
89
88
  const message = (mkfifo.stderr || mkfifo.stdout || '').trim() || 'mkfifo failed';
90
89
  throw new CliError('TMUX_STREAM_ERROR', message);
91
90
  }
92
- try { fs.chmodSync(pipeFile, 0o600); } catch {}
91
+ fs.chmodSync(pipeFile, 0o600);
93
92
  session.streamFd = fs.openSync(pipeFile, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
94
93
  });
95
94
  } catch (err) {