@logicseek/hello-cc 0.1.7 → 0.1.8

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
@@ -227,6 +227,8 @@ const {
227
227
  helpDown,
228
228
  helpUpdate,
229
229
  helpUninstall,
230
+ helpInstallHooks,
231
+ helpShim,
230
232
  helpWeb
231
233
  } = createHelpFunctions({
232
234
  productName: PRODUCT_NAME,
@@ -279,7 +281,11 @@ function splitProcessArgs(line) {
279
281
 
280
282
  function sameResolvedPath(a, b) {
281
283
  if (!a || !b) return false;
282
- return path.resolve(a) === path.resolve(b);
284
+ function key(value) {
285
+ try { return fs.realpathSync(value); }
286
+ catch { return path.resolve(value); }
287
+ }
288
+ return key(a) === key(b);
283
289
  }
284
290
 
285
291
  const {
@@ -731,6 +737,7 @@ async function cmdTask(ctx, args) {
731
737
  const sub = args[0];
732
738
  if (!sub || wantsHelp(args)) return helpTask();
733
739
  if (sub === 'create') return taskCreate(ctx, args.slice(1));
740
+ if (sub === 'dispatch') return taskDispatch(ctx, args.slice(1));
734
741
  if (sub === 'list') return taskList(ctx, args.slice(1));
735
742
  if (sub === 'claim') return taskClaim(ctx, args.slice(1));
736
743
  if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
@@ -772,6 +779,201 @@ async function taskCreate(ctx, args) {
772
779
  (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
773
780
  }
774
781
 
782
+ function dispatchPromptText(task, customMessage = null) {
783
+ if (customMessage) return customMessage;
784
+ return [
785
+ `Please pick up hello-cc task #${task.id}: ${task.title}.`,
786
+ `Run hcc task claim --id ${task.id}, then follow project coordination rules, create a handoff, and mark the task done when finished.`
787
+ ].join(' ');
788
+ }
789
+
790
+ function currentOwnedTaskForPeer(db, peer) {
791
+ return db.prepare(`
792
+ SELECT *
793
+ FROM tasks
794
+ WHERE owner = ?
795
+ AND status IN ('claimed', 'running', 'review', 'blocked')
796
+ ORDER BY
797
+ CASE status
798
+ WHEN 'running' THEN 0
799
+ WHEN 'claimed' THEN 1
800
+ WHEN 'review' THEN 2
801
+ WHEN 'blocked' THEN 3
802
+ ELSE 4
803
+ END,
804
+ priority ASC,
805
+ id ASC
806
+ LIMIT 1
807
+ `).get(peer);
808
+ }
809
+
810
+ function findRuntimeSessionForPeer(runtimeData, peer) {
811
+ return (runtimeData?.sessions || []).find((session) => {
812
+ const sessionPeer = session.peer_id || session.id;
813
+ return session.status === 'running' && (session.id === peer || sessionPeer === peer);
814
+ }) || null;
815
+ }
816
+
817
+ function sessionLooksProviderInteractive(session) {
818
+ if (!['claude', 'codex'].includes(session?.kind)) return false;
819
+ if (session.provider_session_known) return true;
820
+ const command = String(session.command || '');
821
+ if (/^tmux\s+%/.test(command)) return false;
822
+ return /\b(?:claude|codex)(?:\s|$)/.test(command);
823
+ }
824
+
825
+ async function taskDispatch(ctx, args) {
826
+ const opts = parseOpts(args, { booleans: ['force', 'no-inject'] });
827
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
828
+ const actor = identity.id;
829
+ const target = required(opts, 'to');
830
+ const requestedTaskId = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
831
+ const title = requestedTaskId ? (opts.title || null) : required(opts, 'title');
832
+ const body = opts.body || '';
833
+ const priority = intOpt(opts, 'priority', 100);
834
+ const customMessage = opts.message ? String(opts.message) : null;
835
+ const injectAllowed = !Boolean(opts['no-inject']);
836
+
837
+ let task = null;
838
+ let messageId = null;
839
+ let currentTask = null;
840
+ let previousAssignee = null;
841
+ const db = connect(ctx);
842
+ try {
843
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
844
+ const t = now();
845
+ task = tx(db, () => {
846
+ if (requestedTaskId) {
847
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
848
+ if (!existing) throw new CliError('NOT_FOUND', `Task #${requestedTaskId} does not exist`);
849
+ if (['done', 'abandoned'].includes(existing.status)) {
850
+ throw new CliError('BAD_STATE', `Task #${requestedTaskId} is ${existing.status}`);
851
+ }
852
+ if (existing.owner && existing.owner !== target) {
853
+ throw new CliError('TASK_OWNED', `Task #${requestedTaskId} is owned by ${existing.owner}`, {
854
+ owner: existing.owner,
855
+ task_id: requestedTaskId,
856
+ attempted_by: actor,
857
+ target
858
+ });
859
+ }
860
+ previousAssignee = existing.assignee || null;
861
+ db.prepare('UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?').run(target, t, requestedTaskId);
862
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
863
+ }
864
+ const info = db.prepare(`
865
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
866
+ VALUES (?, ?, 'pending', ?, NULL, NULL, NULL, ?, ?, ?, ?)
867
+ `).run(title, body, target, priority, actor, t, t);
868
+ const taskId = Number(info.lastInsertRowid);
869
+ addEvent(db, 'task.created', actor, taskId, { title, assignee: target, priority, parent_id: null, team_role: null });
870
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
871
+ });
872
+ const durableMessage = dispatchPromptText(task, customMessage);
873
+ messageId = sendMessage(db, actor, target, task.id, 'task', durableMessage);
874
+ currentTask = currentOwnedTaskForPeer(db, target);
875
+ } finally {
876
+ db.close();
877
+ }
878
+
879
+ const durableMessage = dispatchPromptText(task, customMessage);
880
+ let session = null;
881
+ let injected = false;
882
+ let injectionReason = injectAllowed ? 'runtime_unavailable' : 'no_inject';
883
+ const busyTask = currentTask && Number(currentTask.id) !== Number(task.id) ? currentTask : null;
884
+ if (injectAllowed) {
885
+ let runtime = null;
886
+ let runtimeData = null;
887
+ try {
888
+ runtime = readRuntime(ctx);
889
+ runtimeData = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
890
+ } catch (err) {
891
+ if (!(err instanceof CliError) || !['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) throw err;
892
+ }
893
+ session = runtimeData ? findRuntimeSessionForPeer(runtimeData, target) : null;
894
+ if (!session) {
895
+ injectionReason = runtimeData ? 'session_not_running' : 'runtime_unavailable';
896
+ } else if (!customMessage && !sessionLooksProviderInteractive(session)) {
897
+ injectionReason = 'unsupported_session_kind';
898
+ } else if (busyTask && !Boolean(opts.force)) {
899
+ injectionReason = 'target_busy';
900
+ } else {
901
+ try {
902
+ await injectPeer(ctx, target, durableMessage, true, runtime, actor);
903
+ injected = true;
904
+ injectionReason = 'injected';
905
+ } catch (err) {
906
+ if (!(err instanceof CliError)) throw err;
907
+ if (['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
908
+ injectionReason = 'runtime_unavailable';
909
+ } else if (['NOT_FOUND', 'SESSION_NOT_RUNNING'].includes(err.code)) {
910
+ injectionReason = 'session_not_running';
911
+ } else {
912
+ throw err;
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ const eventDb = connect(ctx);
919
+ try {
920
+ addEvent(eventDb, 'task.dispatched', actor, task.id, auditPayload({
921
+ actor,
922
+ target,
923
+ source: 'cli',
924
+ admin: actor !== target,
925
+ peer: target,
926
+ title: task.title,
927
+ message_id: messageId,
928
+ injected,
929
+ delivery: injected ? 'message+inject' : 'message-only',
930
+ injection_reason: injectionReason,
931
+ session_id: session?.id || null,
932
+ session_kind: session?.kind || null,
933
+ previous_assignee: previousAssignee,
934
+ blocked_by_task_id: busyTask?.id || null
935
+ }));
936
+ } finally {
937
+ eventDb.close();
938
+ }
939
+
940
+ const result = {
941
+ task,
942
+ target,
943
+ message_id: messageId,
944
+ message: durableMessage,
945
+ injected,
946
+ delivery: injected ? 'message+inject' : 'message-only',
947
+ injection_reason: injectionReason,
948
+ session: session ? {
949
+ id: session.id,
950
+ peer_id: session.peer_id || session.id,
951
+ kind: session.kind,
952
+ status: session.status
953
+ } : null,
954
+ previous_assignee: previousAssignee,
955
+ blocked_by_task: busyTask ? {
956
+ id: busyTask.id,
957
+ status: busyTask.status,
958
+ title: busyTask.title
959
+ } : null
960
+ };
961
+ printResult(ctx, result, (data) => {
962
+ const base = `dispatched task #${data.task.id} to ${data.target} with message #${data.message_id}`;
963
+ if (data.injected) return `${base} and injected live input`;
964
+ if (data.injection_reason === 'target_busy' && data.blocked_by_task) {
965
+ return `${base} (not injected: ${data.target} already owns task #${data.blocked_by_task.id})`;
966
+ }
967
+ if (data.injection_reason === 'unsupported_session_kind' && data.session) {
968
+ return `${base} (not injected: managed ${data.session.kind} session needs an explicit shell-safe message)`;
969
+ }
970
+ if (data.injection_reason === 'session_not_running') return `${base} (not injected: target is not a running managed session)`;
971
+ if (data.injection_reason === 'runtime_unavailable') return `${base} (not injected: web runtime is unavailable)`;
972
+ if (data.injection_reason === 'no_inject') return `${base} (message only)`;
973
+ return `${base} (not injected: ${data.injection_reason})`;
974
+ });
975
+ }
976
+
775
977
  async function taskList(ctx, args) {
776
978
  const opts = parseOpts(args, { booleans: ['all'] });
777
979
  const status = opts.status || null;
@@ -1195,7 +1397,7 @@ async function cmdAsk(ctx, args) {
1195
1397
  const id = sendMessage(db, sender, recipient, taskId, kind, body);
1196
1398
  let injected = false;
1197
1399
  if (opts.inject) {
1198
- await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime);
1400
+ await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime, sender);
1199
1401
  injected = true;
1200
1402
  }
1201
1403
  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' : ''}`);
@@ -1219,15 +1421,15 @@ async function cmdBroadcast(ctx, args) {
1219
1421
  const sessions = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
1220
1422
  const running = (sessions.sessions || []).filter((session) => session.status === 'running');
1221
1423
  for (const session of running) {
1222
- await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime);
1424
+ await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
1223
1425
  injected += 1;
1224
1426
  }
1225
1427
  }
1226
1428
  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)` : ''}`);
1227
1429
  }
1228
1430
 
1229
- async function injectPeer(ctx, peer, text, enter = true, runtime = null) {
1230
- const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1431
+ async function injectPeer(ctx, peer, text, enter = true, runtime = null, auditActor = null) {
1432
+ const actor = auditActor || resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1231
1433
  const db = connect(ctx);
1232
1434
  try {
1233
1435
  addEvent(db, 'web.session.input.requested', actor, null, auditPayload({
@@ -1731,6 +1933,7 @@ async function cmdUp(ctx, args) {
1731
1933
  if (r.hooks.claudeInstalled) lines.push('Claude Code hooks installed');
1732
1934
  if (r.hooks.codexInstalled) lines.push('Codex hooks installed');
1733
1935
  if (r.detected.length) lines.push(`detected: ${r.detected.map((s) => s.peerId).join(', ')}`);
1936
+ if (r.warnings?.length) lines.push(...r.warnings.map((warning) => `warning: ${warning}`));
1734
1937
  lines.push('web: run hcc web when you need browser terminal control');
1735
1938
  return lines.join('\n');
1736
1939
  });
@@ -1747,31 +1950,42 @@ async function prepareLocalBus(ctx, opts = {}) {
1747
1950
 
1748
1951
  const hooks = { claudeInstalled: false, codexInstalled: false };
1749
1952
  const shims = { installed: [], skipped: [], pathUpdated: false, rcFile: null };
1953
+ const warnings = [];
1750
1954
  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());
1955
+ const setup = await loadSetup();
1956
+ try {
1957
+ if (!setup.verifyClaudeHooks()) {
1958
+ setup.installClaudeHooks(commandPath());
1959
+ hooks.claudeInstalled = true;
1960
+ }
1961
+ } catch (err) {
1962
+ warnings.push(`Claude Code hooks installation failed: ${err.message}`);
1963
+ }
1964
+ try {
1965
+ if (!setup.verifyCodexHooks()) {
1966
+ setup.installCodexHooks(commandPath());
1761
1967
  hooks.codexInstalled = true;
1762
- } catch {}
1968
+ }
1969
+ } catch (err) {
1970
+ warnings.push(`Codex hooks installation failed: ${err.message}`);
1763
1971
  }
1764
1972
  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;
1973
+ try {
1974
+ const result = setup.installShims(commandPath());
1975
+ shims.installed = result.installed;
1976
+ shims.skipped = result.skipped;
1977
+ if (result.installed.length) {
1978
+ const pathResult = setup.installPathEntry();
1979
+ shims.pathUpdated = !pathResult.alreadyPresent;
1980
+ shims.rcFile = pathResult.rcFile;
1981
+ }
1982
+ } catch (err) {
1983
+ warnings.push(`shim installation failed: ${err.message}`);
1772
1984
  }
1773
1985
  }
1774
- } catch {}
1986
+ } catch (err) {
1987
+ warnings.push(`local integration setup failed: ${err.message}`);
1988
+ }
1775
1989
 
1776
1990
  const detected = [];
1777
1991
  if (!opts['no-discover']) {
@@ -1781,7 +1995,7 @@ async function prepareLocalBus(ctx, opts = {}) {
1781
1995
  ...scanClaudeSessions(),
1782
1996
  ...scanCodexSessions(),
1783
1997
  ...scanProcesses(),
1784
- ].filter((s) => s.hccRoot === ctx.root);
1998
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
1785
1999
  const byId = new Map();
1786
2000
  for (const s of found) {
1787
2001
  if (!byId.has(s.peerId)) byId.set(s.peerId, s);
@@ -1814,7 +2028,8 @@ async function prepareLocalBus(ctx, opts = {}) {
1814
2028
  guidance,
1815
2029
  hooks,
1816
2030
  shims,
1817
- detected
2031
+ detected,
2032
+ warnings
1818
2033
  };
1819
2034
  }
1820
2035
 
@@ -1870,7 +2085,10 @@ async function startWebBackground(ctx, args) {
1870
2085
  validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
1871
2086
  validateWebTokenOpts(opts);
1872
2087
  ensureTmuxAvailable({ autoInstall: true });
1873
- const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
2088
+ const setup = await prepareLocalBus(ctx, {
2089
+ ...opts,
2090
+ installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
2091
+ });
1874
2092
  registerProject(ctx);
1875
2093
 
1876
2094
  const existing = await readHealthyGlobalRuntime();
@@ -1994,6 +2212,9 @@ function printWebRuntime(ctx, runtime, opts = {}) {
1994
2212
  lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
1995
2213
  }
1996
2214
  }
2215
+ if (opts.setup?.warnings?.length) {
2216
+ lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
2217
+ }
1997
2218
  lines.push(`stop: ${r.stop}`);
1998
2219
  return lines.join('\n');
1999
2220
  });
@@ -2041,7 +2262,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2041
2262
 
2042
2263
  function knownProjects() {
2043
2264
  const rows = readProjectRegistry();
2044
- if (!rows.some((p) => p.root === ctx.root)) rows.unshift(projectRecord(ctx));
2265
+ if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
2045
2266
  for (const project of rows) {
2046
2267
  if (!projectContexts.has(project.root)) {
2047
2268
  projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
@@ -2066,7 +2287,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2066
2287
  }
2067
2288
 
2068
2289
  function sessionsForProject(projectCtx) {
2069
- return [...sessions.values()].filter((session) => session.root === projectCtx.root);
2290
+ return [...sessions.values()].filter((session) => sameResolvedPath(session.root, projectCtx.root));
2070
2291
  }
2071
2292
 
2072
2293
  function getSession(projectCtx, id, db = null) {
@@ -2538,6 +2759,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2538
2759
  return open;
2539
2760
  }
2540
2761
 
2762
+ function closeSessionClients(session) {
2763
+ if (!session?.clients?.size) return;
2764
+ for (const client of [...session.clients]) {
2765
+ try {
2766
+ if (client.readyState === client.OPEN || client.readyState === 1) client.close(1001, 'runtime stopping');
2767
+ else if (typeof client.terminate === 'function') client.terminate();
2768
+ } catch {
2769
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
2770
+ }
2771
+ }
2772
+ }
2773
+
2541
2774
  function detachTmuxSession(session, status = 'detached') {
2542
2775
  stopTmuxStream(session);
2543
2776
  if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
@@ -4089,11 +4322,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4089
4322
  });
4090
4323
  });
4091
4324
 
4325
+ let shuttingDown = false;
4092
4326
  function shutdown() {
4327
+ if (shuttingDown) return;
4328
+ shuttingDown = true;
4093
4329
  clearRuntime(ctx);
4094
4330
  clearInterval(externalScanPoller);
4095
4331
  clearInterval(autoAttachPoller);
4096
4332
  for (const session of sessions.values()) {
4333
+ closeSessionClients(session);
4097
4334
  if (session.status !== 'running') continue;
4098
4335
  if (session.type === 'external') {
4099
4336
  try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
@@ -4106,7 +4343,28 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4106
4343
  try { session.pty.kill(); } catch {}
4107
4344
  }
4108
4345
  }
4109
- server.close(() => process.exit(0));
4346
+ try { wss.close(); } catch {}
4347
+ const terminateClients = setTimeout(() => {
4348
+ for (const session of sessions.values()) {
4349
+ for (const client of [...(session.clients || [])]) {
4350
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
4351
+ }
4352
+ }
4353
+ try { server.closeAllConnections?.(); } catch {}
4354
+ }, 250);
4355
+ const forceExit = setTimeout(() => process.exit(0), 1500);
4356
+ try {
4357
+ server.close(() => {
4358
+ clearTimeout(terminateClients);
4359
+ clearTimeout(forceExit);
4360
+ process.exit(0);
4361
+ });
4362
+ try { server.closeIdleConnections?.(); } catch {}
4363
+ } catch {
4364
+ clearTimeout(terminateClients);
4365
+ clearTimeout(forceExit);
4366
+ process.exit(0);
4367
+ }
4110
4368
  }
4111
4369
  process.once('SIGINT', shutdown);
4112
4370
  process.once('SIGTERM', shutdown);
@@ -4556,6 +4814,7 @@ async function cmdHook(ctx, args) {
4556
4814
  // ─── hcc install-hooks ───────────────────────────────────────────────────────
4557
4815
 
4558
4816
  async function cmdInstallHooks(ctx, args) {
4817
+ if (wantsHelp(args)) return helpInstallHooks();
4559
4818
  const opts = parseOpts(args, { booleans: ['uninstall', 'status'] });
4560
4819
  const { installClaudeHooks, uninstallClaudeHooks, verifyClaudeHooks,
4561
4820
  installCodexHooks, uninstallCodexHooks, verifyCodexHooks } = await loadSetup();
@@ -4585,11 +4844,20 @@ async function cmdInstallHooks(ctx, args) {
4585
4844
  `hooks installed: claude → ${cp}${cxp ? `, codex → ${cxp}` : ''}`);
4586
4845
  }
4587
4846
 
4847
+ function pathEntryRemovalMessage(pathEntry) {
4848
+ if (pathEntry.error) return `PATH entry not removed: ${pathEntry.error}`;
4849
+ if (pathEntry.missing) return `PATH entry not present (${pathEntry.rcFile} not found)`;
4850
+ if (pathEntry.removed === false) return `PATH entry not present in ${pathEntry.rcFile}`;
4851
+ return `PATH entry removed from ${pathEntry.rcFile}`;
4852
+ }
4853
+
4588
4854
  // ─── hcc shim ────────────────────────────────────────────────────────────────
4589
4855
 
4590
4856
  async function cmdShim(ctx, args) {
4591
4857
  const sub = args[0];
4592
- const { installShims, uninstallShims, verifyShims, installPathEntry, SHIM_DIR } = await loadSetup();
4858
+ const { installShims, uninstallShims, shimStatus, installPathEntry, uninstallPathEntry, SHIM_DIR } = await loadSetup();
4859
+
4860
+ if (wantsHelp(args)) return helpShim();
4593
4861
 
4594
4862
  if (sub === 'ensure') {
4595
4863
  const name = args[1];
@@ -4597,9 +4865,10 @@ async function cmdShim(ctx, args) {
4597
4865
  if (!['claude', 'codex'].includes(name) || !target) {
4598
4866
  throw new CliError('BAD_ARGS', 'Usage: hcc shim ensure claude|codex PATH');
4599
4867
  }
4600
- const result = installShims(commandPath());
4868
+ const realBin = args[3] || null;
4869
+ const result = installShims(commandPath(), realBin ? { realBins: { [name]: realBin } } : {});
4601
4870
  const changed = (result.changed || []).map((p) => path.resolve(p));
4602
- if (changed.includes(target)) {
4871
+ if (changed.some((p) => sameResolvedPath(p, target))) {
4603
4872
  process.exitCode = 75;
4604
4873
  return;
4605
4874
  }
@@ -4609,32 +4878,45 @@ async function cmdShim(ctx, args) {
4609
4878
  if (!sub || sub === 'install') {
4610
4879
  const hccBin = commandPath();
4611
4880
  const result = installShims(hccBin);
4612
- const { alreadyPresent, rcFile } = installPathEntry();
4613
4881
  const lines = [
4614
4882
  result.installed.length
4615
4883
  ? `shims installed:\n${result.installed.map((p) => ` ${p}`).join('\n')}`
4616
4884
  : 'no shims installed (claude/codex not found on PATH)',
4617
4885
  ];
4618
4886
  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}`);
4887
+ if (result.installed.length) {
4888
+ const { alreadyPresent, rcFile } = installPathEntry();
4889
+ if (!alreadyPresent) {
4890
+ lines.push(`PATH updated in ${rcFile}`);
4891
+ lines.push(`run: source ${rcFile} (or open a new terminal)`);
4892
+ } else {
4893
+ lines.push(`PATH entry already present in ${rcFile}`);
4894
+ }
4624
4895
  }
4625
4896
  printResult(ctx, result, () => lines.join('\n'));
4626
4897
  return;
4627
4898
  }
4628
4899
  if (sub === 'uninstall') {
4629
4900
  const removed = uninstallShims();
4630
- printResult(ctx, { removed }, () => removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove');
4901
+ const pathEntry = uninstallPathEntry();
4902
+ printResult(ctx, { removed, path_entry: pathEntry }, () => [
4903
+ removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove',
4904
+ pathEntryRemovalMessage(pathEntry)
4905
+ ].join('\n'));
4631
4906
  return;
4632
4907
  }
4633
4908
  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
- );
4909
+ const status = shimStatus();
4910
+ printResult(ctx, status, (r) => [
4911
+ `shim dir: ${r.shimDir}`,
4912
+ `claude: ${r.tools.claude.installed ? 'installed' : 'missing'} (${r.tools.claude.path})`,
4913
+ `codex: ${r.tools.codex.installed ? 'installed' : 'missing'} (${r.tools.codex.path})`,
4914
+ r.complete
4915
+ ? 'status: complete'
4916
+ : r.installed
4917
+ ? 'status: partial (run: hcc shim install)'
4918
+ : 'status: not installed (run: hcc shim install)'
4919
+ ].join('\n'));
4638
4920
  return;
4639
4921
  }
4640
4922
  throw new CliError('BAD_ARGS', `Unknown shim subcommand: ${sub}`);
@@ -4660,7 +4942,7 @@ async function cmdSetup(ctx, args) {
4660
4942
 
4661
4943
  // 2. Install Claude Code + Codex hooks
4662
4944
  const { installClaudeHooks, verifyClaudeHooks, installCodexHooks, verifyCodexHooks,
4663
- installShims, verifyShims, installPathEntry } = await loadSetup();
4945
+ installShims, installPathEntry } = await loadSetup();
4664
4946
  const hccBin = commandPath();
4665
4947
 
4666
4948
  if (!verifyClaudeHooks()) {
@@ -4678,20 +4960,23 @@ async function cmdSetup(ctx, args) {
4678
4960
  }
4679
4961
 
4680
4962
  // 3. Install shims
4681
- if (!verifyShims()) {
4682
- const result = installShims(hccBin);
4683
- if (result.installed.length) {
4684
- log(`✓ shims installed → ${result.installed.join(', ')}`);
4963
+ const result = installShims(hccBin);
4964
+ if (result.installed.length) {
4965
+ if (result.changed.length) {
4966
+ log(`✓ shims installed → ${result.changed.join(', ')}`);
4685
4967
  } else {
4686
- log('⚠ shims: claude/codex not found on PATH — skipped');
4968
+ log(`✓ shims already installed → ${result.installed.join(', ')}`);
4687
4969
  }
4970
+ if (result.skipped.length) log(`⚠ shims skipped: ${result.skipped.join(', ')}`);
4688
4971
  const { alreadyPresent, rcFile } = installPathEntry();
4689
4972
  if (!alreadyPresent) {
4690
4973
  log(`✓ PATH updated in ${rcFile}`);
4691
4974
  log(` run: source ${rcFile} (or open a new terminal)`);
4975
+ } else {
4976
+ log(`✓ PATH entry already present in ${rcFile}`);
4692
4977
  }
4693
4978
  } else {
4694
- log('✓ shims already installed');
4979
+ log('⚠ shims: claude/codex not found on PATH — skipped');
4695
4980
  }
4696
4981
 
4697
4982
  log('\nDone. Run `hcc web` for the default coordinated terminal experience.');
@@ -4771,13 +5056,15 @@ async function cmdUninstall(ctx, args) {
4771
5056
  lines.push('runtime not running');
4772
5057
  }
4773
5058
 
4774
- const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims } = await loadSetup();
5059
+ const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
4775
5060
  const claude = uninstallClaudeHooks();
4776
5061
  const codex = uninstallCodexHooks();
4777
5062
  const shims = uninstallShims();
5063
+ const pathEntry = uninstallPathEntry();
4778
5064
  lines.push(claude ? 'Claude Code hooks removed' : 'Claude Code hooks not found');
4779
5065
  lines.push(codex ? 'Codex hooks removed' : 'Codex hooks not found');
4780
5066
  lines.push(shims.length ? `shims removed: ${shims.join(', ')}` : 'shims not found');
5067
+ lines.push(pathEntryRemovalMessage(pathEntry));
4781
5068
 
4782
5069
  let guidance = [];
4783
5070
  let purged = false;
@@ -4795,7 +5082,7 @@ async function cmdUninstall(ctx, args) {
4795
5082
  lines.push('project data kept; run hcc uninstall --purge --yes to remove .hello-cc and guidance blocks');
4796
5083
  }
4797
5084
 
4798
- printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, purge: purged, guidance }, () => lines.join('\n'));
5085
+ printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
4799
5086
  }
4800
5087
 
4801
5088
  // ─── hcc scan ────────────────────────────────────────────────────────────────
@@ -4808,7 +5095,7 @@ async function cmdScan(ctx, args) {
4808
5095
  ...scanClaudeSessions(),
4809
5096
  ...scanCodexSessions(),
4810
5097
  ...scanProcesses(),
4811
- ].filter((s) => s.hccRoot === ctx.root);
5098
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
4812
5099
 
4813
5100
  // Deduplicate by peerId
4814
5101
  const byId = new Map();
package/docs/commands.md CHANGED
@@ -12,7 +12,7 @@ hcc uninstall [--purge --yes]
12
12
  ```
13
13
 
14
14
  `hcc update` updates the global npm install. `hcc uninstall` removes local
15
- hooks and shims; add `--purge --yes` only when you also want to remove the
15
+ hooks, shims, and the shell PATH entry; add `--purge --yes` only when you also want to remove the
16
16
  current project's `.hello-cc` data and guidance blocks.
17
17
 
18
18
  ## Start And Stop
@@ -93,6 +93,8 @@ sender by default. Use `msg thread` to inspect the full thread for one message.
93
93
 
94
94
  ```text
95
95
  hcc task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
96
+ hcc task dispatch --to ID --title TEXT [--body TEXT] [--from ID] [--message TEXT] [--no-inject] [--force]
97
+ hcc task dispatch --to ID --id N [--from ID] [--message TEXT] [--no-inject] [--force]
96
98
  hcc task list [--status S] [--peer ID] [--all]
97
99
  hcc task claim [--peer ID] --id N[,N] [--id N] [--ids N,N] [--force]
98
100
  hcc task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--stale-after SECONDS]
@@ -107,11 +109,16 @@ Tasks are shared project facts. They remain visible until marked `done` or
107
109
  task before claiming a new pending task; use `--force` only when intentionally
108
110
  taking additional pending tasks, and combine it with `--count N` for explicit
109
111
  batch claims. `task claim` also accepts repeated `--id` values and comma-separated
110
- `--id` or `--ids` lists. Use `task takeover` when explicitly taking a
111
- non-complete task from another owner; it records the previous owner, requires a
112
- reason, and notifies them. Add `--policy blocked`, `stale`, or `blocked-or-stale`
113
- when takeover should only proceed under that auditable precondition. The default
114
- policy is `any` for compatibility.
112
+ `--id` or `--ids` lists. `task dispatch` is the explicit one-step form for
113
+ assigning a new or existing task to one peer, sending the durable task message,
114
+ and injecting the startup prompt only when that peer has a running managed
115
+ Claude/Codex terminal. Use `--no-inject` for message-only dispatch, and use
116
+ `--force` only when intentionally injecting while the target already owns another
117
+ active task. Use `task takeover` when explicitly taking a non-complete task from
118
+ another owner; it records the previous owner, requires a reason, and notifies
119
+ them. Add `--policy blocked`, `stale`, or `blocked-or-stale` when takeover
120
+ should only proceed under that auditable precondition. The default policy is
121
+ `any` for compatibility.
115
122
 
116
123
  ## Teams
117
124