@logicseek/hello-cc 0.1.7 → 0.1.9

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/bin/hcc.mjs CHANGED
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
+ import { randomBytes } from 'node:crypto';
7
8
  import { spawn, spawnSync } from 'node:child_process';
8
9
  import { setTimeout as sleep } from 'node:timers/promises';
9
10
  import { URL, fileURLToPath } from 'node:url';
@@ -227,6 +228,8 @@ const {
227
228
  helpDown,
228
229
  helpUpdate,
229
230
  helpUninstall,
231
+ helpInstallHooks,
232
+ helpShim,
230
233
  helpWeb
231
234
  } = createHelpFunctions({
232
235
  productName: PRODUCT_NAME,
@@ -279,7 +282,11 @@ function splitProcessArgs(line) {
279
282
 
280
283
  function sameResolvedPath(a, b) {
281
284
  if (!a || !b) return false;
282
- return path.resolve(a) === path.resolve(b);
285
+ function key(value) {
286
+ try { return fs.realpathSync(value); }
287
+ catch { return path.resolve(value); }
288
+ }
289
+ return key(a) === key(b);
283
290
  }
284
291
 
285
292
  const {
@@ -731,6 +738,7 @@ async function cmdTask(ctx, args) {
731
738
  const sub = args[0];
732
739
  if (!sub || wantsHelp(args)) return helpTask();
733
740
  if (sub === 'create') return taskCreate(ctx, args.slice(1));
741
+ if (sub === 'dispatch') return taskDispatch(ctx, args.slice(1));
734
742
  if (sub === 'list') return taskList(ctx, args.slice(1));
735
743
  if (sub === 'claim') return taskClaim(ctx, args.slice(1));
736
744
  if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
@@ -772,6 +780,201 @@ async function taskCreate(ctx, args) {
772
780
  (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
773
781
  }
774
782
 
783
+ function dispatchPromptText(task, customMessage = null) {
784
+ if (customMessage) return customMessage;
785
+ return [
786
+ `Please pick up hello-cc task #${task.id}: ${task.title}.`,
787
+ `Run hcc task claim --id ${task.id}, then follow project coordination rules, create a handoff, and mark the task done when finished.`
788
+ ].join(' ');
789
+ }
790
+
791
+ function currentOwnedTaskForPeer(db, peer) {
792
+ return db.prepare(`
793
+ SELECT *
794
+ FROM tasks
795
+ WHERE owner = ?
796
+ AND status IN ('claimed', 'running', 'review', 'blocked')
797
+ ORDER BY
798
+ CASE status
799
+ WHEN 'running' THEN 0
800
+ WHEN 'claimed' THEN 1
801
+ WHEN 'review' THEN 2
802
+ WHEN 'blocked' THEN 3
803
+ ELSE 4
804
+ END,
805
+ priority ASC,
806
+ id ASC
807
+ LIMIT 1
808
+ `).get(peer);
809
+ }
810
+
811
+ function findRuntimeSessionForPeer(runtimeData, peer) {
812
+ return (runtimeData?.sessions || []).find((session) => {
813
+ const sessionPeer = session.peer_id || session.id;
814
+ return session.status === 'running' && (session.id === peer || sessionPeer === peer);
815
+ }) || null;
816
+ }
817
+
818
+ function sessionLooksProviderInteractive(session) {
819
+ if (!['claude', 'codex'].includes(session?.kind)) return false;
820
+ if (session.provider_session_known) return true;
821
+ const command = String(session.command || '');
822
+ if (/^tmux\s+%/.test(command)) return false;
823
+ return /\b(?:claude|codex)(?:\s|$)/.test(command);
824
+ }
825
+
826
+ async function taskDispatch(ctx, args) {
827
+ const opts = parseOpts(args, { booleans: ['force', 'no-inject'] });
828
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
829
+ const actor = identity.id;
830
+ const target = required(opts, 'to');
831
+ const requestedTaskId = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
832
+ const title = requestedTaskId ? (opts.title || null) : required(opts, 'title');
833
+ const body = opts.body || '';
834
+ const priority = intOpt(opts, 'priority', 100);
835
+ const customMessage = opts.message ? String(opts.message) : null;
836
+ const injectAllowed = !Boolean(opts['no-inject']);
837
+
838
+ let task = null;
839
+ let messageId = null;
840
+ let currentTask = null;
841
+ let previousAssignee = null;
842
+ const db = connect(ctx);
843
+ try {
844
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
845
+ const t = now();
846
+ task = tx(db, () => {
847
+ if (requestedTaskId) {
848
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
849
+ if (!existing) throw new CliError('NOT_FOUND', `Task #${requestedTaskId} does not exist`);
850
+ if (['done', 'abandoned'].includes(existing.status)) {
851
+ throw new CliError('BAD_STATE', `Task #${requestedTaskId} is ${existing.status}`);
852
+ }
853
+ if (existing.owner && existing.owner !== target) {
854
+ throw new CliError('TASK_OWNED', `Task #${requestedTaskId} is owned by ${existing.owner}`, {
855
+ owner: existing.owner,
856
+ task_id: requestedTaskId,
857
+ attempted_by: actor,
858
+ target
859
+ });
860
+ }
861
+ previousAssignee = existing.assignee || null;
862
+ db.prepare('UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?').run(target, t, requestedTaskId);
863
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
864
+ }
865
+ const info = db.prepare(`
866
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
867
+ VALUES (?, ?, 'pending', ?, NULL, NULL, NULL, ?, ?, ?, ?)
868
+ `).run(title, body, target, priority, actor, t, t);
869
+ const taskId = Number(info.lastInsertRowid);
870
+ addEvent(db, 'task.created', actor, taskId, { title, assignee: target, priority, parent_id: null, team_role: null });
871
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
872
+ });
873
+ const durableMessage = dispatchPromptText(task, customMessage);
874
+ messageId = sendMessage(db, actor, target, task.id, 'task', durableMessage);
875
+ currentTask = currentOwnedTaskForPeer(db, target);
876
+ } finally {
877
+ db.close();
878
+ }
879
+
880
+ const durableMessage = dispatchPromptText(task, customMessage);
881
+ let session = null;
882
+ let injected = false;
883
+ let injectionReason = injectAllowed ? 'runtime_unavailable' : 'no_inject';
884
+ const busyTask = currentTask && Number(currentTask.id) !== Number(task.id) ? currentTask : null;
885
+ if (injectAllowed) {
886
+ let runtime = null;
887
+ let runtimeData = null;
888
+ try {
889
+ runtime = readRuntime(ctx);
890
+ runtimeData = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
891
+ } catch (err) {
892
+ if (!(err instanceof CliError) || !['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) throw err;
893
+ }
894
+ session = runtimeData ? findRuntimeSessionForPeer(runtimeData, target) : null;
895
+ if (!session) {
896
+ injectionReason = runtimeData ? 'session_not_running' : 'runtime_unavailable';
897
+ } else if (!customMessage && !sessionLooksProviderInteractive(session)) {
898
+ injectionReason = 'unsupported_session_kind';
899
+ } else if (busyTask && !Boolean(opts.force)) {
900
+ injectionReason = 'target_busy';
901
+ } else {
902
+ try {
903
+ await injectPeer(ctx, target, durableMessage, true, runtime, actor);
904
+ injected = true;
905
+ injectionReason = 'injected';
906
+ } catch (err) {
907
+ if (!(err instanceof CliError)) throw err;
908
+ if (['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
909
+ injectionReason = 'runtime_unavailable';
910
+ } else if (['NOT_FOUND', 'SESSION_NOT_RUNNING'].includes(err.code)) {
911
+ injectionReason = 'session_not_running';
912
+ } else {
913
+ throw err;
914
+ }
915
+ }
916
+ }
917
+ }
918
+
919
+ const eventDb = connect(ctx);
920
+ try {
921
+ addEvent(eventDb, 'task.dispatched', actor, task.id, auditPayload({
922
+ actor,
923
+ target,
924
+ source: 'cli',
925
+ admin: actor !== target,
926
+ peer: target,
927
+ title: task.title,
928
+ message_id: messageId,
929
+ injected,
930
+ delivery: injected ? 'message+inject' : 'message-only',
931
+ injection_reason: injectionReason,
932
+ session_id: session?.id || null,
933
+ session_kind: session?.kind || null,
934
+ previous_assignee: previousAssignee,
935
+ blocked_by_task_id: busyTask?.id || null
936
+ }));
937
+ } finally {
938
+ eventDb.close();
939
+ }
940
+
941
+ const result = {
942
+ task,
943
+ target,
944
+ message_id: messageId,
945
+ message: durableMessage,
946
+ injected,
947
+ delivery: injected ? 'message+inject' : 'message-only',
948
+ injection_reason: injectionReason,
949
+ session: session ? {
950
+ id: session.id,
951
+ peer_id: session.peer_id || session.id,
952
+ kind: session.kind,
953
+ status: session.status
954
+ } : null,
955
+ previous_assignee: previousAssignee,
956
+ blocked_by_task: busyTask ? {
957
+ id: busyTask.id,
958
+ status: busyTask.status,
959
+ title: busyTask.title
960
+ } : null
961
+ };
962
+ printResult(ctx, result, (data) => {
963
+ const base = `dispatched task #${data.task.id} to ${data.target} with message #${data.message_id}`;
964
+ if (data.injected) return `${base} and injected live input`;
965
+ if (data.injection_reason === 'target_busy' && data.blocked_by_task) {
966
+ return `${base} (not injected: ${data.target} already owns task #${data.blocked_by_task.id})`;
967
+ }
968
+ if (data.injection_reason === 'unsupported_session_kind' && data.session) {
969
+ return `${base} (not injected: managed ${data.session.kind} session needs an explicit shell-safe message)`;
970
+ }
971
+ if (data.injection_reason === 'session_not_running') return `${base} (not injected: target is not a running managed session)`;
972
+ if (data.injection_reason === 'runtime_unavailable') return `${base} (not injected: web runtime is unavailable)`;
973
+ if (data.injection_reason === 'no_inject') return `${base} (message only)`;
974
+ return `${base} (not injected: ${data.injection_reason})`;
975
+ });
976
+ }
977
+
775
978
  async function taskList(ctx, args) {
776
979
  const opts = parseOpts(args, { booleans: ['all'] });
777
980
  const status = opts.status || null;
@@ -1195,7 +1398,7 @@ async function cmdAsk(ctx, args) {
1195
1398
  const id = sendMessage(db, sender, recipient, taskId, kind, body);
1196
1399
  let injected = false;
1197
1400
  if (opts.inject) {
1198
- await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime);
1401
+ await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime, sender);
1199
1402
  injected = true;
1200
1403
  }
1201
1404
  printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, injected }, (data) => `asked ${data.recipient} with message #${data.id}${data.injected ? ' and injected terminal input' : ''}`);
@@ -1215,19 +1418,32 @@ async function cmdBroadcast(ctx, args) {
1215
1418
  touchCurrentPeer(db, ctx, identity, null, 'shell');
1216
1419
  const id = sendMessage(db, sender, 'all', taskId, kind, body);
1217
1420
  let injected = 0;
1421
+ let skipped = 0;
1218
1422
  if (opts.inject) {
1219
1423
  const sessions = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
1220
1424
  const running = (sessions.sessions || []).filter((session) => session.status === 'running');
1221
1425
  for (const session of running) {
1222
- await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime);
1223
- injected += 1;
1426
+ try {
1427
+ await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
1428
+ injected += 1;
1429
+ } catch (err) {
1430
+ if (err instanceof CliError && ['NOT_FOUND', 'SESSION_NOT_RUNNING', 'TMUX_ERROR'].includes(err.code)) {
1431
+ skipped += 1;
1432
+ continue;
1433
+ }
1434
+ throw err;
1435
+ }
1224
1436
  }
1225
1437
  }
1226
- printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected }, (data) => `broadcast message #${data.id}${data.injected ? ` and injected ${data.injected} terminal(s)` : ''}`);
1438
+ printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected, skipped }, (data) => {
1439
+ const injectedText = data.injected ? ` and injected ${data.injected} terminal(s)` : '';
1440
+ const skippedText = data.skipped ? `, skipped ${data.skipped} stale terminal(s)` : '';
1441
+ return `broadcast message #${data.id}${injectedText}${skippedText}`;
1442
+ });
1227
1443
  }
1228
1444
 
1229
- async function injectPeer(ctx, peer, text, enter = true, runtime = null) {
1230
- const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1445
+ async function injectPeer(ctx, peer, text, enter = true, runtime = null, auditActor = null) {
1446
+ const actor = auditActor || resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1231
1447
  const db = connect(ctx);
1232
1448
  try {
1233
1449
  addEvent(db, 'web.session.input.requested', actor, null, auditPayload({
@@ -1731,6 +1947,7 @@ async function cmdUp(ctx, args) {
1731
1947
  if (r.hooks.claudeInstalled) lines.push('Claude Code hooks installed');
1732
1948
  if (r.hooks.codexInstalled) lines.push('Codex hooks installed');
1733
1949
  if (r.detected.length) lines.push(`detected: ${r.detected.map((s) => s.peerId).join(', ')}`);
1950
+ if (r.warnings?.length) lines.push(...r.warnings.map((warning) => `warning: ${warning}`));
1734
1951
  lines.push('web: run hcc web when you need browser terminal control');
1735
1952
  return lines.join('\n');
1736
1953
  });
@@ -1747,31 +1964,42 @@ async function prepareLocalBus(ctx, opts = {}) {
1747
1964
 
1748
1965
  const hooks = { claudeInstalled: false, codexInstalled: false };
1749
1966
  const shims = { installed: [], skipped: [], pathUpdated: false, rcFile: null };
1967
+ const warnings = [];
1750
1968
  try {
1751
- const { verifyClaudeHooks, installClaudeHooks,
1752
- verifyCodexHooks, installCodexHooks,
1753
- installShims, installPathEntry } = await loadSetup();
1754
- if (!verifyClaudeHooks()) {
1755
- installClaudeHooks(commandPath());
1756
- hooks.claudeInstalled = true;
1757
- }
1758
- if (!verifyCodexHooks()) {
1759
- try {
1760
- installCodexHooks(commandPath());
1969
+ const setup = await loadSetup();
1970
+ try {
1971
+ if (!setup.verifyClaudeHooks()) {
1972
+ setup.installClaudeHooks(commandPath());
1973
+ hooks.claudeInstalled = true;
1974
+ }
1975
+ } catch (err) {
1976
+ warnings.push(`Claude Code hooks installation failed: ${err.message}`);
1977
+ }
1978
+ try {
1979
+ if (!setup.verifyCodexHooks()) {
1980
+ setup.installCodexHooks(commandPath());
1761
1981
  hooks.codexInstalled = true;
1762
- } catch {}
1982
+ }
1983
+ } catch (err) {
1984
+ warnings.push(`Codex hooks installation failed: ${err.message}`);
1763
1985
  }
1764
1986
  if (opts.installShims) {
1765
- const result = installShims(commandPath());
1766
- shims.installed = result.installed;
1767
- shims.skipped = result.skipped;
1768
- if (result.installed.length) {
1769
- const pathResult = installPathEntry();
1770
- shims.pathUpdated = !pathResult.alreadyPresent;
1771
- shims.rcFile = pathResult.rcFile;
1987
+ try {
1988
+ const result = setup.installShims(commandPath());
1989
+ shims.installed = result.installed;
1990
+ shims.skipped = result.skipped;
1991
+ if (result.installed.length) {
1992
+ const pathResult = setup.installPathEntry();
1993
+ shims.pathUpdated = !pathResult.alreadyPresent;
1994
+ shims.rcFile = pathResult.rcFile;
1995
+ }
1996
+ } catch (err) {
1997
+ warnings.push(`shim installation failed: ${err.message}`);
1772
1998
  }
1773
1999
  }
1774
- } catch {}
2000
+ } catch (err) {
2001
+ warnings.push(`local integration setup failed: ${err.message}`);
2002
+ }
1775
2003
 
1776
2004
  const detected = [];
1777
2005
  if (!opts['no-discover']) {
@@ -1781,7 +2009,7 @@ async function prepareLocalBus(ctx, opts = {}) {
1781
2009
  ...scanClaudeSessions(),
1782
2010
  ...scanCodexSessions(),
1783
2011
  ...scanProcesses(),
1784
- ].filter((s) => s.hccRoot === ctx.root);
2012
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
1785
2013
  const byId = new Map();
1786
2014
  for (const s of found) {
1787
2015
  if (!byId.has(s.peerId)) byId.set(s.peerId, s);
@@ -1814,7 +2042,8 @@ async function prepareLocalBus(ctx, opts = {}) {
1814
2042
  guidance,
1815
2043
  hooks,
1816
2044
  shims,
1817
- detected
2045
+ detected,
2046
+ warnings
1818
2047
  };
1819
2048
  }
1820
2049
 
@@ -1829,11 +2058,21 @@ function hccWebProcessMatches(line, ctx) {
1829
2058
  sameResolvedPath(global.db, ctx.dbPath);
1830
2059
  }
1831
2060
 
2061
+ function currentProcessAncestorPids(ppidByPid) {
2062
+ const ancestors = new Set();
2063
+ let pid = process.ppid;
2064
+ while (Number.isFinite(pid) && pid > 0 && !ancestors.has(pid)) {
2065
+ ancestors.add(pid);
2066
+ pid = ppidByPid.get(pid);
2067
+ }
2068
+ return ancestors;
2069
+ }
2070
+
1832
2071
  async function stopOrphanWebRuntimes(ctx, keepPid = null) {
1833
2072
  if (process.platform === 'win32') return;
1834
2073
  let output = '';
1835
2074
  try {
1836
- output = spawnSync('ps', ['-eo', 'pid=,args='], {
2075
+ output = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], {
1837
2076
  encoding: 'utf8',
1838
2077
  stdio: ['ignore', 'pipe', 'ignore']
1839
2078
  }).stdout || '';
@@ -1841,15 +2080,25 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
1841
2080
  return;
1842
2081
  }
1843
2082
 
1844
- const pids = [];
2083
+ const rows = [];
2084
+ const ppidByPid = new Map();
1845
2085
  for (const line of output.split('\n')) {
1846
2086
  const trimmed = line.trim();
1847
2087
  if (!trimmed) continue;
1848
- const match = trimmed.match(/^(\d+)\s+(.*)$/);
2088
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
1849
2089
  if (!match) continue;
1850
2090
  const pid = Number.parseInt(match[1], 10);
1851
- if (!Number.isFinite(pid) || pid === process.pid || pid === keepPid) continue;
1852
- if (hccWebProcessMatches(match[2], ctx)) pids.push(pid);
2091
+ const ppid = Number.parseInt(match[2], 10);
2092
+ if (!Number.isFinite(pid)) continue;
2093
+ if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
2094
+ rows.push({ pid, args: match[3] });
2095
+ }
2096
+
2097
+ const ancestorPids = currentProcessAncestorPids(ppidByPid);
2098
+ const pids = [];
2099
+ for (const row of rows) {
2100
+ if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
2101
+ if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
1853
2102
  }
1854
2103
  if (!pids.length) return;
1855
2104
 
@@ -1870,7 +2119,10 @@ async function startWebBackground(ctx, args) {
1870
2119
  validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
1871
2120
  validateWebTokenOpts(opts);
1872
2121
  ensureTmuxAvailable({ autoInstall: true });
1873
- const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
2122
+ const setup = await prepareLocalBus(ctx, {
2123
+ ...opts,
2124
+ installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
2125
+ });
1874
2126
  registerProject(ctx);
1875
2127
 
1876
2128
  const existing = await readHealthyGlobalRuntime();
@@ -1994,6 +2246,9 @@ function printWebRuntime(ctx, runtime, opts = {}) {
1994
2246
  lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
1995
2247
  }
1996
2248
  }
2249
+ if (opts.setup?.warnings?.length) {
2250
+ lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
2251
+ }
1997
2252
  lines.push(`stop: ${r.stop}`);
1998
2253
  return lines.join('\n');
1999
2254
  });
@@ -2029,7 +2284,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2029
2284
  const prepared = await prepareLocalBus(ctx, opts);
2030
2285
 
2031
2286
  function newSessionActionToken() {
2032
- return makeWebToken({ token: null });
2287
+ return randomBytes(32).toString('base64url');
2033
2288
  }
2034
2289
 
2035
2290
  function rememberProject(projectCtx) {
@@ -2041,7 +2296,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2041
2296
 
2042
2297
  function knownProjects() {
2043
2298
  const rows = readProjectRegistry();
2044
- if (!rows.some((p) => p.root === ctx.root)) rows.unshift(projectRecord(ctx));
2299
+ if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
2045
2300
  for (const project of rows) {
2046
2301
  if (!projectContexts.has(project.root)) {
2047
2302
  projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
@@ -2066,7 +2321,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2066
2321
  }
2067
2322
 
2068
2323
  function sessionsForProject(projectCtx) {
2069
- return [...sessions.values()].filter((session) => session.root === projectCtx.root);
2324
+ return [...sessions.values()].filter((session) => sameResolvedPath(session.root, projectCtx.root));
2070
2325
  }
2071
2326
 
2072
2327
  function getSession(projectCtx, id, db = null) {
@@ -2538,6 +2793,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2538
2793
  return open;
2539
2794
  }
2540
2795
 
2796
+ function closeSessionClients(session) {
2797
+ if (!session?.clients?.size) return;
2798
+ for (const client of [...session.clients]) {
2799
+ try {
2800
+ if (client.readyState === client.OPEN || client.readyState === 1) client.close(1001, 'runtime stopping');
2801
+ else if (typeof client.terminate === 'function') client.terminate();
2802
+ } catch {
2803
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
2804
+ }
2805
+ }
2806
+ }
2807
+
2541
2808
  function detachTmuxSession(session, status = 'detached') {
2542
2809
  stopTmuxStream(session);
2543
2810
  if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
@@ -4089,11 +4356,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4089
4356
  });
4090
4357
  });
4091
4358
 
4359
+ let shuttingDown = false;
4092
4360
  function shutdown() {
4361
+ if (shuttingDown) return;
4362
+ shuttingDown = true;
4093
4363
  clearRuntime(ctx);
4094
4364
  clearInterval(externalScanPoller);
4095
4365
  clearInterval(autoAttachPoller);
4096
4366
  for (const session of sessions.values()) {
4367
+ closeSessionClients(session);
4097
4368
  if (session.status !== 'running') continue;
4098
4369
  if (session.type === 'external') {
4099
4370
  try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
@@ -4106,7 +4377,28 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4106
4377
  try { session.pty.kill(); } catch {}
4107
4378
  }
4108
4379
  }
4109
- server.close(() => process.exit(0));
4380
+ try { wss.close(); } catch {}
4381
+ const terminateClients = setTimeout(() => {
4382
+ for (const session of sessions.values()) {
4383
+ for (const client of [...(session.clients || [])]) {
4384
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
4385
+ }
4386
+ }
4387
+ try { server.closeAllConnections?.(); } catch {}
4388
+ }, 250);
4389
+ const forceExit = setTimeout(() => process.exit(0), 1500);
4390
+ try {
4391
+ server.close(() => {
4392
+ clearTimeout(terminateClients);
4393
+ clearTimeout(forceExit);
4394
+ process.exit(0);
4395
+ });
4396
+ try { server.closeIdleConnections?.(); } catch {}
4397
+ } catch {
4398
+ clearTimeout(terminateClients);
4399
+ clearTimeout(forceExit);
4400
+ process.exit(0);
4401
+ }
4110
4402
  }
4111
4403
  process.once('SIGINT', shutdown);
4112
4404
  process.once('SIGTERM', shutdown);
@@ -4556,6 +4848,7 @@ async function cmdHook(ctx, args) {
4556
4848
  // ─── hcc install-hooks ───────────────────────────────────────────────────────
4557
4849
 
4558
4850
  async function cmdInstallHooks(ctx, args) {
4851
+ if (wantsHelp(args)) return helpInstallHooks();
4559
4852
  const opts = parseOpts(args, { booleans: ['uninstall', 'status'] });
4560
4853
  const { installClaudeHooks, uninstallClaudeHooks, verifyClaudeHooks,
4561
4854
  installCodexHooks, uninstallCodexHooks, verifyCodexHooks } = await loadSetup();
@@ -4585,11 +4878,20 @@ async function cmdInstallHooks(ctx, args) {
4585
4878
  `hooks installed: claude → ${cp}${cxp ? `, codex → ${cxp}` : ''}`);
4586
4879
  }
4587
4880
 
4881
+ function pathEntryRemovalMessage(pathEntry) {
4882
+ if (pathEntry.error) return `PATH entry not removed: ${pathEntry.error}`;
4883
+ if (pathEntry.missing) return `PATH entry not present (${pathEntry.rcFile} not found)`;
4884
+ if (pathEntry.removed === false) return `PATH entry not present in ${pathEntry.rcFile}`;
4885
+ return `PATH entry removed from ${pathEntry.rcFile}`;
4886
+ }
4887
+
4588
4888
  // ─── hcc shim ────────────────────────────────────────────────────────────────
4589
4889
 
4590
4890
  async function cmdShim(ctx, args) {
4591
4891
  const sub = args[0];
4592
- const { installShims, uninstallShims, verifyShims, installPathEntry, SHIM_DIR } = await loadSetup();
4892
+ const { installShims, uninstallShims, shimStatus, installPathEntry, uninstallPathEntry, SHIM_DIR } = await loadSetup();
4893
+
4894
+ if (wantsHelp(args)) return helpShim();
4593
4895
 
4594
4896
  if (sub === 'ensure') {
4595
4897
  const name = args[1];
@@ -4597,9 +4899,10 @@ async function cmdShim(ctx, args) {
4597
4899
  if (!['claude', 'codex'].includes(name) || !target) {
4598
4900
  throw new CliError('BAD_ARGS', 'Usage: hcc shim ensure claude|codex PATH');
4599
4901
  }
4600
- const result = installShims(commandPath());
4902
+ const realBin = args[3] || null;
4903
+ const result = installShims(commandPath(), realBin ? { realBins: { [name]: realBin } } : {});
4601
4904
  const changed = (result.changed || []).map((p) => path.resolve(p));
4602
- if (changed.includes(target)) {
4905
+ if (changed.some((p) => sameResolvedPath(p, target))) {
4603
4906
  process.exitCode = 75;
4604
4907
  return;
4605
4908
  }
@@ -4609,32 +4912,45 @@ async function cmdShim(ctx, args) {
4609
4912
  if (!sub || sub === 'install') {
4610
4913
  const hccBin = commandPath();
4611
4914
  const result = installShims(hccBin);
4612
- const { alreadyPresent, rcFile } = installPathEntry();
4613
4915
  const lines = [
4614
4916
  result.installed.length
4615
4917
  ? `shims installed:\n${result.installed.map((p) => ` ${p}`).join('\n')}`
4616
4918
  : 'no shims installed (claude/codex not found on PATH)',
4617
4919
  ];
4618
4920
  if (result.skipped.length) lines.push(`skipped: ${result.skipped.join(', ')}`);
4619
- if (!alreadyPresent) {
4620
- lines.push(`PATH updated in ${rcFile}`);
4621
- lines.push(`run: source ${rcFile} (or open a new terminal)`);
4622
- } else {
4623
- lines.push(`PATH entry already present in ${rcFile}`);
4921
+ if (result.installed.length) {
4922
+ const { alreadyPresent, rcFile } = installPathEntry();
4923
+ if (!alreadyPresent) {
4924
+ lines.push(`PATH updated in ${rcFile}`);
4925
+ lines.push(`run: source ${rcFile} (or open a new terminal)`);
4926
+ } else {
4927
+ lines.push(`PATH entry already present in ${rcFile}`);
4928
+ }
4624
4929
  }
4625
4930
  printResult(ctx, result, () => lines.join('\n'));
4626
4931
  return;
4627
4932
  }
4628
4933
  if (sub === 'uninstall') {
4629
4934
  const removed = uninstallShims();
4630
- printResult(ctx, { removed }, () => removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove');
4935
+ const pathEntry = uninstallPathEntry();
4936
+ printResult(ctx, { removed, path_entry: pathEntry }, () => [
4937
+ removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove',
4938
+ pathEntryRemovalMessage(pathEntry)
4939
+ ].join('\n'));
4631
4940
  return;
4632
4941
  }
4633
4942
  if (sub === 'status') {
4634
- const installed = verifyShims();
4635
- printResult(ctx, { installed, shim_dir: SHIM_DIR }, () =>
4636
- installed ? `shims installed in ${SHIM_DIR} ✓` : `shims not installed (run: hcc web)`
4637
- );
4943
+ const status = shimStatus();
4944
+ printResult(ctx, status, (r) => [
4945
+ `shim dir: ${r.shimDir}`,
4946
+ `claude: ${r.tools.claude.installed ? 'installed' : 'missing'} (${r.tools.claude.path})`,
4947
+ `codex: ${r.tools.codex.installed ? 'installed' : 'missing'} (${r.tools.codex.path})`,
4948
+ r.complete
4949
+ ? 'status: complete'
4950
+ : r.installed
4951
+ ? 'status: partial (run: hcc shim install)'
4952
+ : 'status: not installed (run: hcc shim install)'
4953
+ ].join('\n'));
4638
4954
  return;
4639
4955
  }
4640
4956
  throw new CliError('BAD_ARGS', `Unknown shim subcommand: ${sub}`);
@@ -4660,7 +4976,7 @@ async function cmdSetup(ctx, args) {
4660
4976
 
4661
4977
  // 2. Install Claude Code + Codex hooks
4662
4978
  const { installClaudeHooks, verifyClaudeHooks, installCodexHooks, verifyCodexHooks,
4663
- installShims, verifyShims, installPathEntry } = await loadSetup();
4979
+ installShims, installPathEntry } = await loadSetup();
4664
4980
  const hccBin = commandPath();
4665
4981
 
4666
4982
  if (!verifyClaudeHooks()) {
@@ -4678,20 +4994,23 @@ async function cmdSetup(ctx, args) {
4678
4994
  }
4679
4995
 
4680
4996
  // 3. Install shims
4681
- if (!verifyShims()) {
4682
- const result = installShims(hccBin);
4683
- if (result.installed.length) {
4684
- log(`✓ shims installed → ${result.installed.join(', ')}`);
4997
+ const result = installShims(hccBin);
4998
+ if (result.installed.length) {
4999
+ if (result.changed.length) {
5000
+ log(`✓ shims installed → ${result.changed.join(', ')}`);
4685
5001
  } else {
4686
- log('⚠ shims: claude/codex not found on PATH — skipped');
5002
+ log(`✓ shims already installed → ${result.installed.join(', ')}`);
4687
5003
  }
5004
+ if (result.skipped.length) log(`⚠ shims skipped: ${result.skipped.join(', ')}`);
4688
5005
  const { alreadyPresent, rcFile } = installPathEntry();
4689
5006
  if (!alreadyPresent) {
4690
5007
  log(`✓ PATH updated in ${rcFile}`);
4691
5008
  log(` run: source ${rcFile} (or open a new terminal)`);
5009
+ } else {
5010
+ log(`✓ PATH entry already present in ${rcFile}`);
4692
5011
  }
4693
5012
  } else {
4694
- log('✓ shims already installed');
5013
+ log('⚠ shims: claude/codex not found on PATH — skipped');
4695
5014
  }
4696
5015
 
4697
5016
  log('\nDone. Run `hcc web` for the default coordinated terminal experience.');
@@ -4771,13 +5090,15 @@ async function cmdUninstall(ctx, args) {
4771
5090
  lines.push('runtime not running');
4772
5091
  }
4773
5092
 
4774
- const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims } = await loadSetup();
5093
+ const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
4775
5094
  const claude = uninstallClaudeHooks();
4776
5095
  const codex = uninstallCodexHooks();
4777
5096
  const shims = uninstallShims();
5097
+ const pathEntry = uninstallPathEntry();
4778
5098
  lines.push(claude ? 'Claude Code hooks removed' : 'Claude Code hooks not found');
4779
5099
  lines.push(codex ? 'Codex hooks removed' : 'Codex hooks not found');
4780
5100
  lines.push(shims.length ? `shims removed: ${shims.join(', ')}` : 'shims not found');
5101
+ lines.push(pathEntryRemovalMessage(pathEntry));
4781
5102
 
4782
5103
  let guidance = [];
4783
5104
  let purged = false;
@@ -4795,7 +5116,7 @@ async function cmdUninstall(ctx, args) {
4795
5116
  lines.push('project data kept; run hcc uninstall --purge --yes to remove .hello-cc and guidance blocks');
4796
5117
  }
4797
5118
 
4798
- printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, purge: purged, guidance }, () => lines.join('\n'));
5119
+ printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
4799
5120
  }
4800
5121
 
4801
5122
  // ─── hcc scan ────────────────────────────────────────────────────────────────
@@ -4808,7 +5129,7 @@ async function cmdScan(ctx, args) {
4808
5129
  ...scanClaudeSessions(),
4809
5130
  ...scanCodexSessions(),
4810
5131
  ...scanProcesses(),
4811
- ].filter((s) => s.hccRoot === ctx.root);
5132
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
4812
5133
 
4813
5134
  // Deduplicate by peerId
4814
5135
  const byId = new Map();