@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.
@@ -38,7 +38,8 @@ const managedTmuxSessions = new Set();
38
38
  const env = {
39
39
  ...process.env,
40
40
  HOME: home,
41
- PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`
41
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
42
+ SHELL: '/bin/bash'
42
43
  };
43
44
  for (const key of Object.keys(env)) {
44
45
  if (key.startsWith('HCC_')) delete env[key];
@@ -51,6 +52,13 @@ function log(message) {
51
52
  }
52
53
 
53
54
  function fail(message) {
55
+ if (process.env.GITHUB_ACTIONS === 'true') {
56
+ const escaped = String(message)
57
+ .replace(/%/g, '%25')
58
+ .replace(/\r/g, '%0D')
59
+ .replace(/\n/g, '%0A');
60
+ process.stderr.write(`::error::${escaped}\n`);
61
+ }
54
62
  throw new Error(message);
55
63
  }
56
64
 
@@ -62,6 +70,20 @@ function sh(value) {
62
70
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
63
71
  }
64
72
 
73
+ function canonicalPath(value) {
74
+ try { return fs.realpathSync(value); }
75
+ catch { return path.resolve(value); }
76
+ }
77
+
78
+ function samePath(a, b) {
79
+ return canonicalPath(a) === canonicalPath(b);
80
+ }
81
+
82
+ function statusValue(output, key) {
83
+ const match = String(output || '').match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
84
+ return match ? match[1].trim() : '';
85
+ }
86
+
65
87
  function shortHash(value) {
66
88
  return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
67
89
  }
@@ -400,7 +422,8 @@ async function assertTmuxGcPolicy() {
400
422
  }), t);
401
423
  });
402
424
 
403
- const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec tmux attach-session -t ${sh(attachedSession)}`], {
425
+ const attachTmux = realTmuxBin ? `${sh(realTmuxBin)} -L ${sh(tmuxSocketName)}` : 'tmux';
426
+ const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec ${attachTmux} attach-session -t ${sh(attachedSession)}`], {
404
427
  env,
405
428
  encoding: 'utf8',
406
429
  stdio: ['ignore', 'pipe', 'pipe']
@@ -1023,19 +1046,34 @@ async function waitForFile(file, expected, label = file) {
1023
1046
  }
1024
1047
 
1025
1048
  async function waitForFileContent(file, expected, label = file) {
1026
- await waitFor(() => {
1027
- if (!fs.existsSync(file)) return false;
1028
- return fs.readFileSync(file, 'utf8').trim() === expected;
1029
- }, label);
1049
+ const deadline = Date.now() + 10000;
1050
+ let actual = '(missing)';
1051
+ while (Date.now() < deadline) {
1052
+ if (fs.existsSync(file)) {
1053
+ actual = fs.readFileSync(file, 'utf8').trim();
1054
+ if (actual === expected) break;
1055
+ }
1056
+ await sleep(100);
1057
+ }
1058
+ if (actual !== expected) {
1059
+ fail(`timed out waiting for ${label}\nexpected: ${expected}\nactual: ${actual}`);
1060
+ }
1030
1061
  ensureFile(file, expected);
1031
1062
  }
1032
1063
 
1033
1064
  async function waitForFileLineCount(file, expected, label = file) {
1034
- await waitFor(() => {
1035
- if (!fs.existsSync(file)) return false;
1036
- const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
1037
- return lines.length === expected;
1038
- }, label);
1065
+ const deadline = Date.now() + 10000;
1066
+ let actual = '(missing)';
1067
+ while (Date.now() < deadline) {
1068
+ if (fs.existsSync(file)) {
1069
+ actual = fs.readFileSync(file, 'utf8').trim();
1070
+ const lines = actual.split('\n').filter(Boolean);
1071
+ if (lines.length === expected) return;
1072
+ }
1073
+ await sleep(100);
1074
+ }
1075
+ const count = actual === '(missing)' ? 0 : actual.split('\n').filter(Boolean).length;
1076
+ fail(`timed out waiting for ${label}\nexpected lines: ${expected}\nactual lines: ${count}\nactual: ${actual}`);
1039
1077
  }
1040
1078
 
1041
1079
  async function waitRuntime() {
@@ -1051,6 +1089,19 @@ async function waitRuntime() {
1051
1089
  }, 'runtime');
1052
1090
  }
1053
1091
 
1092
+ function processIsAlive(pid) {
1093
+ try {
1094
+ process.kill(pid, 0);
1095
+ return true;
1096
+ } catch {
1097
+ return false;
1098
+ }
1099
+ }
1100
+
1101
+ async function waitForProcessExit(pid, label, timeoutMs = 5000) {
1102
+ await waitFor(() => !processIsAlive(pid), label, timeoutMs);
1103
+ }
1104
+
1054
1105
  async function expectWebSocketMarker(peer, marker) {
1055
1106
  await new Promise((resolve, reject) => {
1056
1107
  let sawSnapshot = false;
@@ -1077,6 +1128,21 @@ async function expectWebSocketMarker(peer, marker) {
1077
1128
  });
1078
1129
  }
1079
1130
 
1131
+ async function openTerminalWebSocket(peer) {
1132
+ return new Promise((resolve, reject) => {
1133
+ const ws = new WebSocket(runtimeWsUrl(peer));
1134
+ const timer = setTimeout(() => reject(new Error(`${peer} websocket open timeout`)), 5000);
1135
+ ws.on('open', () => {
1136
+ clearTimeout(timer);
1137
+ resolve(ws);
1138
+ });
1139
+ ws.on('error', (err) => {
1140
+ clearTimeout(timer);
1141
+ reject(err);
1142
+ });
1143
+ });
1144
+ }
1145
+
1080
1146
  async function expectResizeReplaceSnapshot(peer, marker) {
1081
1147
  await new Promise((resolve, reject) => {
1082
1148
  let sawSnapshot = false;
@@ -1196,17 +1262,20 @@ function startRuntime(options = {}) {
1196
1262
 
1197
1263
  async function stopRuntime() {
1198
1264
  if (!runtimePid) return;
1265
+ const pid = runtimePid;
1199
1266
  hccMaybe(['down']);
1200
- await waitFor(() => {
1267
+ try {
1268
+ await waitForProcessExit(pid, 'runtime process exit', 5000);
1269
+ } catch (err) {
1270
+ try { process.kill(pid, 'SIGTERM'); } catch {}
1201
1271
  try {
1202
- process.kill(runtimePid, 0);
1203
- return false;
1272
+ await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
1204
1273
  } catch {
1205
- return true;
1274
+ try { process.kill(pid, 'SIGKILL'); } catch {}
1275
+ try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
1276
+ throw err;
1206
1277
  }
1207
- }, 'runtime process exit', 5000).catch(() => {
1208
- try { process.kill(runtimePid, 'SIGTERM'); } catch {}
1209
- });
1278
+ }
1210
1279
  runtimePid = null;
1211
1280
  }
1212
1281
 
@@ -1315,7 +1384,12 @@ async function setupRegression() {
1315
1384
  fail(`interactive bash did not keep codex shim first: ${interactiveCodex}`);
1316
1385
  }
1317
1386
  if (!hcc(['install-hooks', '--status']).includes('claude=yes codex=yes')) fail('hooks not installed');
1318
- if (!hcc(['shim', 'status']).includes('shims installed')) fail('shims not installed');
1387
+ const shimStatusOutput = hcc(['shim', 'status']);
1388
+ if (!shimStatusOutput.includes('claude: installed') ||
1389
+ !shimStatusOutput.includes('codex: installed') ||
1390
+ !shimStatusOutput.includes('status: complete')) {
1391
+ fail(`shims not fully installed:\n${shimStatusOutput}`);
1392
+ }
1319
1393
  const staleShim = path.join(home, '.hcc-shims', 'claude');
1320
1394
  fs.writeFileSync(staleShim, '#!/usr/bin/env bash\necho stale --web-managed\n', { mode: 0o755 });
1321
1395
  const ensured = hccMaybe(['shim', 'ensure', 'claude', staleShim]);
@@ -1326,6 +1400,25 @@ async function setupRegression() {
1326
1400
  if (!refreshedShim.includes('shim ensure "claude"') || refreshedShim.includes('stale --web-managed')) {
1327
1401
  fail(`shim ensure did not refresh stale shim:\n${refreshedShim}`);
1328
1402
  }
1403
+ const deadRealBin = path.join(outDir, 'removed-claude');
1404
+ fs.rmSync(deadRealBin, { force: true });
1405
+ fs.writeFileSync(staleShim, [
1406
+ '#!/usr/bin/env bash',
1407
+ '# hello-cc shim for claude (auto-generated; do not edit manually)',
1408
+ `# Real binary: ${deadRealBin}`,
1409
+ `REAL_BIN=${sh(deadRealBin)}`,
1410
+ 'exec "$REAL_BIN" "$@"',
1411
+ ''
1412
+ ].join('\n'), { mode: 0o755 });
1413
+ const rediscovered = hccMaybe(['shim', 'ensure', 'claude', staleShim, deadRealBin]);
1414
+ if (rediscovered.status !== 75) {
1415
+ fail(`shim ensure did not request re-exec for stale real binary:\n${rediscovered.stdout}\n${rediscovered.stderr}`);
1416
+ }
1417
+ const rediscoveredShim = fs.readFileSync(staleShim, 'utf8');
1418
+ const expectedRealBin = path.join(fakeBin, 'claude');
1419
+ if (!rediscoveredShim.includes(`# Real binary: ${expectedRealBin}`) || rediscoveredShim.includes(deadRealBin)) {
1420
+ fail(`shim ensure preserved stale real binary instead of rediscovering PATH binary:\n${rediscoveredShim}`);
1421
+ }
1329
1422
  await stopRuntime();
1330
1423
  assertPeerBindingUniqueConstraints();
1331
1424
  assertLegacySchemaMigration();
@@ -1417,16 +1510,18 @@ async function setupRegression() {
1417
1510
  const childDir = path.join(root, 'packages', 'child');
1418
1511
  fs.mkdirSync(childDir, { recursive: true });
1419
1512
  const childFindRoot = hccFrom(['find-root'], childDir).trim();
1420
- if (childFindRoot !== childDir) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1513
+ if (!samePath(childFindRoot, childDir)) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1421
1514
  const childStatus = hccFrom(['status'], childDir);
1422
- if (!childStatus.includes(`root: ${childDir}`) || !childStatus.includes(`db: ${path.join(childDir, '.hello-cc', 'mesh.db')}`)) {
1515
+ if (!samePath(statusValue(childStatus, 'root'), childDir) ||
1516
+ !samePath(statusValue(childStatus, 'db'), path.join(childDir, '.hello-cc', 'mesh.db'))) {
1423
1517
  fail(`child command did not stay on current path:\n${childStatus}`);
1424
1518
  }
1425
1519
  ensureFile(path.join(childDir, '.hello-cc', 'mesh.db'));
1426
1520
  const explicitChildRoot = hccFrom(['--root', root, 'find-root'], childDir).trim();
1427
- if (explicitChildRoot !== root) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1521
+ if (!samePath(explicitChildRoot, root)) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1428
1522
  const explicitChildStatus = hccFrom(['--root', root, 'status'], childDir);
1429
- if (!explicitChildStatus.includes(`root: ${root}`) || !explicitChildStatus.includes(`db: ${path.join(root, '.hello-cc', 'mesh.db')}`)) {
1523
+ if (!samePath(statusValue(explicitChildStatus, 'root'), root) ||
1524
+ !samePath(statusValue(explicitChildStatus, 'db'), path.join(root, '.hello-cc', 'mesh.db'))) {
1430
1525
  fail(`explicit child command did not use requested root:\n${explicitChildStatus}`);
1431
1526
  }
1432
1527
  const childHookPayload = JSON.stringify({ session_id: 'child-hook-session', cwd: childDir, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
@@ -1565,6 +1660,38 @@ async function dbWorkflow() {
1565
1660
  hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
1566
1661
  }
1567
1662
  hcc(['task', 'update', '--peer', 'human', '--id', batchNextIds[2], '--status', 'abandoned', '--summary', 'batch next leftover cleanup']);
1663
+
1664
+ const dispatched = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-a', '--title', 'dispatch new task', '--body', 'dispatch body', '--no-inject']);
1665
+ if (!dispatched.task || dispatched.task.assignee !== 'dispatch-a' || dispatched.task.owner ||
1666
+ dispatched.injected !== false || dispatched.injection_reason !== 'no_inject' || !dispatched.message_id) {
1667
+ fail(`task dispatch did not create message-only assigned task:\n${JSON.stringify(dispatched, null, 2)}`);
1668
+ }
1669
+ const dispatchInbox = hcc(['msg', 'inbox', '--peer', 'dispatch-a', '--all']);
1670
+ if (!dispatchInbox.includes(`task #${dispatched.task.id}`) || !dispatchInbox.includes('dispatch new task')) {
1671
+ fail(`task dispatch durable message missing:\n${dispatchInbox}`);
1672
+ }
1673
+ const dispatchAudit = eventPayloads('task.dispatched', 20)
1674
+ .find((event) => Number(event.payload?.message_id) === Number(dispatched.message_id));
1675
+ if (!dispatchAudit ||
1676
+ dispatchAudit.actor !== 'human' ||
1677
+ dispatchAudit.payload.actor_peer !== 'human' ||
1678
+ dispatchAudit.payload.target_peer !== 'dispatch-a' ||
1679
+ dispatchAudit.payload.source !== 'cli' ||
1680
+ dispatchAudit.payload.injected !== false ||
1681
+ dispatchAudit.payload.injection_reason !== 'no_inject') {
1682
+ fail(`task dispatch audit payload wrong:\n${JSON.stringify(dispatchAudit, null, 2)}`);
1683
+ }
1684
+ const dispatchExistingCreated = hcc(['task', 'create', '--from', 'human', '--title', 'dispatch existing task']);
1685
+ const dispatchExistingMatch = dispatchExistingCreated.match(/created task #(\d+):/);
1686
+ if (!dispatchExistingMatch) fail(`cannot parse dispatch existing task id: ${dispatchExistingCreated}`);
1687
+ const dispatchExisting = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-b', '--id', dispatchExistingMatch[1], '--message', 'custom dispatch existing', '--no-inject']);
1688
+ if (String(dispatchExisting.task?.id) !== dispatchExistingMatch[1] ||
1689
+ dispatchExisting.task.assignee !== 'dispatch-b' ||
1690
+ dispatchExisting.message !== 'custom dispatch existing' ||
1691
+ dispatchExisting.injected !== false) {
1692
+ fail(`task dispatch --id did not assign existing task:\n${JSON.stringify(dispatchExisting, null, 2)}`);
1693
+ }
1694
+
1568
1695
  const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
1569
1696
  const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
1570
1697
  if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
@@ -1846,8 +1973,9 @@ async function multiProjectWebWorkflow() {
1846
1973
  fs.mkdirSync(otherRoot, { recursive: true });
1847
1974
  const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
1848
1975
  if (!output.includes('web already running in background')) fail(`second project did not reuse global web:\n${output}`);
1849
- if (!output.includes(`project: ${otherRoot}`)) fail(`second project output did not show its root:\n${output}`);
1850
- if (!output.includes(encodeURIComponent(otherRoot))) fail(`second project URL did not include project query:\n${output}`);
1976
+ const outputRoot = statusValue(output, 'project');
1977
+ if (!samePath(outputRoot, otherRoot)) fail(`second project output did not show its root:\n${output}`);
1978
+ if (!output.includes(encodeURIComponent(outputRoot))) fail(`second project URL did not include project query:\n${output}`);
1851
1979
 
1852
1980
  const otherRuntimeFile = path.join(otherRoot, '.hello-cc', 'runtime.json');
1853
1981
  ensureFile(otherRuntimeFile);
@@ -1859,8 +1987,9 @@ async function multiProjectWebWorkflow() {
1859
1987
  const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
1860
1988
  const projects = await projectsResponse.json();
1861
1989
  if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
1862
- const roots = new Set((projects.projects || []).map((p) => p.root));
1863
- if (!roots.has(root) || !roots.has(otherRoot)) {
1990
+ const roots = (projects.projects || []).map((p) => p.root);
1991
+ if (!roots.some((projectRoot) => samePath(projectRoot, root)) ||
1992
+ !roots.some((projectRoot) => samePath(projectRoot, otherRoot))) {
1864
1993
  fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
1865
1994
  }
1866
1995
 
@@ -2305,11 +2434,18 @@ async function tmuxBackedStartWorkflow() {
2305
2434
  await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
2306
2435
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
2307
2436
 
2308
- await stopRuntime();
2437
+ const heldWs = await openTerminalWebSocket('shell-a');
2438
+ try {
2439
+ await stopRuntime();
2440
+ await waitFor(() => heldWs.readyState === WebSocket.CLOSED || heldWs.readyState === WebSocket.CLOSING, 'runtime websocket close', 2000);
2441
+ } finally {
2442
+ try { heldWs.close(); } catch {}
2443
+ }
2309
2444
  run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
2310
2445
  startRuntime();
2311
2446
  await waitRuntime();
2312
- await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore');
2447
+ await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore after websocket shutdown');
2448
+
2313
2449
  const restoredFile = path.join(outDir, 'pty-restored-ok');
2314
2450
  hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
2315
2451
  await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
@@ -2471,6 +2607,9 @@ async function shimTmuxWorkflow() {
2471
2607
  const firstEnvFile = path.join(outDir, 'shim-env-first');
2472
2608
  hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
2473
2609
  await waitForFile(firstEnvFile, 'shim-first', 'shim first env');
2610
+ const shimInternalEnvFile = path.join(outDir, 'shim-internal-env');
2611
+ hcc(['inject', peer, `printf '%s|%s\\n' "\${HCC_SHIM_ENSURED-unset}" "\${HCC_SKIP_SHIM_INSTALL-unset}" > ${shimInternalEnvFile}`]);
2612
+ await waitForFile(shimInternalEnvFile, 'unset|unset', 'shim internal env cleanup');
2474
2613
 
2475
2614
  const restarted = run(shim, ['--resume', 'shim-regression-session'], {
2476
2615
  cwd: root,
@@ -2492,12 +2631,34 @@ async function shimTmuxWorkflow() {
2492
2631
  HCC_FAKE_STAY_ALIVE: '0',
2493
2632
  HCC_FAKE_LOG: exitedLog
2494
2633
  };
2495
- const exitedFirst = run(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
2634
+ const exitedFirstResult = runMaybe(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
2635
+ if (exitedFirstResult.status !== 0) {
2636
+ const allShimLines = fs.readFileSync(shim, 'utf8').split('\n');
2637
+ const shimLines = allShimLines
2638
+ .slice(0, 35)
2639
+ .concat(['...'])
2640
+ .concat(allShimLines.slice(139))
2641
+ .map((line, index) => {
2642
+ if (index < 35) return `${index + 1}: ${line}`;
2643
+ if (index === 35) return line;
2644
+ return `${index + 104}: ${line}`;
2645
+ })
2646
+ .join('\n');
2647
+ fail(`${sh([shim, '--resume', exitedResume].join(' '))} failed\n${exitedFirstResult.stdout || ''}${exitedFirstResult.stderr || ''}\nshim excerpt:\n${shimLines}`);
2648
+ }
2649
+ const exitedFirst = exitedFirstResult.stdout || '';
2496
2650
  const exitedPeerMatch = exitedFirst.match(/started\s+(\S+)\s+\(/);
2497
2651
  if (!exitedPeerMatch) fail(`exited shim did not start a peer:\n${exitedFirst}`);
2498
2652
  const exitedPeer = exitedPeerMatch[1];
2499
- parsePane(exitedFirst);
2500
- await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
2653
+ const exitedPane = parsePane(exitedFirst);
2654
+ try {
2655
+ await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
2656
+ } catch (err) {
2657
+ const paneCapture = runMaybe('tmux', ['capture-pane', '-p', '-S', '-80', '-t', exitedPane]);
2658
+ const peerList = hccMaybe(['peer', 'list']);
2659
+ const shimHead = fs.readFileSync(shim, 'utf8').split('\n').slice(0, 18).join('\n');
2660
+ fail(`${err.message}\nshim output:\n${exitedFirst}\nshim stderr:\n${exitedFirstResult.stderr || ''}\npeer list:\n${peerList.stdout || ''}${peerList.stderr || ''}\npane ${exitedPane}:\n${paneCapture.stdout || ''}${paneCapture.stderr || ''}\nshim head:\n${shimHead}`);
2661
+ }
2501
2662
  const fallbackFile = path.join(outDir, 'shim-exited-fallback');
2502
2663
  hcc(['inject', exitedPeer, `printf '%s\\n' fallback > ${sh(fallbackFile)}`]);
2503
2664
  await waitForFile(fallbackFile, 'fallback', 'shim exited fallback shell');
@@ -2546,6 +2707,16 @@ async function askBroadcastWorkflow() {
2546
2707
  hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
2547
2708
  await waitForFile(askFile, 'ASK_OK', 'ask injection');
2548
2709
  if (!hcc(['msg', 'inbox', '--peer', 'shell-b', '--all']).includes('ASK_OK')) fail('ask durable message missing');
2710
+ const shellDefaultDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell default dispatch']);
2711
+ if (shellDefaultDispatch.injected !== false || shellDefaultDispatch.injection_reason !== 'unsupported_session_kind') {
2712
+ fail(`task dispatch injected default natural-language prompt into shell session:\n${JSON.stringify(shellDefaultDispatch, null, 2)}`);
2713
+ }
2714
+ const dispatchFile = path.join(outDir, 'dispatch-ok');
2715
+ const shellCommandDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell command dispatch', '--message', `echo DISPATCH_OK > ${dispatchFile}`, '--force']);
2716
+ if (!shellCommandDispatch.injected || shellCommandDispatch.delivery !== 'message+inject') {
2717
+ fail(`task dispatch did not inject explicit shell-safe message:\n${JSON.stringify(shellCommandDispatch, null, 2)}`);
2718
+ }
2719
+ await waitForFile(dispatchFile, 'DISPATCH_OK', 'task dispatch injection');
2549
2720
  const broadcastFile = path.join(outDir, 'broadcast-ok');
2550
2721
  hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
2551
2722
  await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
@@ -2596,8 +2767,8 @@ async function downGcPackWorkflow() {
2596
2767
  "tags:\n - 'v*'",
2597
2768
  'workflow_dispatch:',
2598
2769
  'contents: write',
2599
- 'actions/checkout@v4',
2600
- 'actions/setup-node@v4',
2770
+ 'actions/checkout@v5',
2771
+ 'actions/setup-node@v5',
2601
2772
  'node-version: 24',
2602
2773
  'npm run release:check',
2603
2774
  'npm run release:github:dry-run',
@@ -2620,16 +2791,23 @@ function oldNameScan() {
2620
2791
  'ACTIVE_' + 'AGENT_' + 'TTL',
2621
2792
  '--' + 'agent\\b'
2622
2793
  ].join('|');
2623
- const rg = runMaybe('rg', [
2624
- '-n',
2625
- '--glob', '!node_modules/**',
2626
- '--glob', '!*.tgz',
2627
- '--',
2628
- oldNamePattern,
2629
- '.'
2630
- ]);
2631
- if (rg.status === 0) fail(`old names found:\n${rg.stdout}`);
2632
- if (rg.status !== 1) fail(`rg failed:\n${rg.stderr || rg.stdout}`);
2794
+ const oldNameRegex = new RegExp(oldNamePattern);
2795
+ const files = run('git', ['ls-files'])
2796
+ .split('\n')
2797
+ .map((file) => file.trim())
2798
+ .filter(Boolean)
2799
+ .filter((file) => !file.includes('/node_modules/') && !file.endsWith('.tgz'));
2800
+ const matches = [];
2801
+ for (const file of files) {
2802
+ const fullPath = path.join(repoRoot, file);
2803
+ let content = '';
2804
+ try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
2805
+ const lines = content.split('\n');
2806
+ for (let i = 0; i < lines.length; i += 1) {
2807
+ if (oldNameRegex.test(lines[i])) matches.push(`${file}:${i + 1}: ${lines[i]}`);
2808
+ }
2809
+ }
2810
+ if (matches.length) fail(`old names found:\n${matches.join('\n')}`);
2633
2811
  }
2634
2812
 
2635
2813
  function identityEnforcementWorkflow() {
@@ -3132,6 +3310,7 @@ async function syntaxAndHelp() {
3132
3310
  'function installShims',
3133
3311
  'function uninstallShims',
3134
3312
  'function verifyShims',
3313
+ 'function shimStatus',
3135
3314
  'function findRealBinary',
3136
3315
  'function fsExists'
3137
3316
  ]) {
@@ -3159,7 +3338,8 @@ async function syntaxAndHelp() {
3159
3338
  }
3160
3339
  if (!integrationShimScriptSource.includes('function generateShim') ||
3161
3340
  !integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
3162
- !integrationShimScriptSource.includes('HCC_SHIM_ENSURED')) {
3341
+ !integrationShimScriptSource.includes('HCC_SHIM_ENSURED') ||
3342
+ !integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL')) {
3163
3343
  fail('integrations shim script module is missing expected generateShim wiring');
3164
3344
  }
3165
3345
  if (!shellPathSource.includes('function installPathEntry') ||
@@ -3184,7 +3364,7 @@ async function syntaxAndHelp() {
3184
3364
  if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
3185
3365
  if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
3186
3366
  }
3187
- for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
3367
+ for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'shimStatus', 'findRealBinary']) {
3188
3368
  if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
3189
3369
  if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
3190
3370
  }
@@ -3198,6 +3378,147 @@ async function syntaxAndHelp() {
3198
3378
  if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
3199
3379
  if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
3200
3380
  }
3381
+ const bashRc = '# regression rc\n[ -z "$PS1" ] && return\nexport PATH="/late:$PATH"\n';
3382
+ const bashPath = shellPath.insertPathEntry(bashRc, 'bash');
3383
+ if ((bashPath.match(/# hello-cc shims/g) || []).length !== 2 ||
3384
+ bashPath.indexOf('# hello-cc shims (early)') > bashPath.indexOf('[ -z "$PS1" ] && return') ||
3385
+ bashPath.lastIndexOf('# hello-cc shims (final)') < bashPath.indexOf('export PATH="/late:$PATH"')) {
3386
+ fail(`bash shell PATH insertion lost early/final placement:\n${bashPath}`);
3387
+ }
3388
+ const zshPath = shellPath.insertPathEntry('export PATH="$HOME/bin:$PATH"\n', 'zsh');
3389
+ if ((zshPath.match(/# hello-cc shims/g) || []).length !== 1 ||
3390
+ zshPath.includes('# hello-cc shims (early)') ||
3391
+ !zshPath.includes('# hello-cc shims (final)')) {
3392
+ fail(`zsh shell PATH insertion should install one final entry:\n${zshPath}`);
3393
+ }
3394
+ const fishPath = shellPath.insertPathEntry('set -gx PATH $HOME/bin $PATH\n', 'fish');
3395
+ if ((fishPath.match(/# hello-cc shims/g) || []).length !== 1 ||
3396
+ fishPath.includes('# hello-cc shims (early)') ||
3397
+ !fishPath.includes('# hello-cc shims (final)')) {
3398
+ fail(`fish shell PATH insertion should install one final entry:\n${fishPath}`);
3399
+ }
3400
+ const customRc = [
3401
+ 'export PATH="$HOME/.hcc-shims:$PATH" # user custom shim dir',
3402
+ shellPath.pathEntryLine('bash', 'final')
3403
+ ].join('\n');
3404
+ const cleanedRc = shellPath.removePathEntryLines(customRc);
3405
+ if (!cleanedRc.includes('user custom shim dir') || cleanedRc.includes('# hello-cc shims')) {
3406
+ fail(`shell PATH cleanup removed a non-hello-cc custom line or kept hello-cc line:\n${cleanedRc}`);
3407
+ }
3408
+ const noProviderHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-no-provider-home-'));
3409
+ try {
3410
+ const noProviderEnv = {
3411
+ ...env,
3412
+ HOME: noProviderHome,
3413
+ PATH: '/usr/bin:/bin',
3414
+ SHELL: '/bin/bash'
3415
+ };
3416
+ const noProviderInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: noProviderEnv });
3417
+ if (!noProviderInstall.includes('no shims installed')) {
3418
+ fail(`shim install without providers did not report no shims:\n${noProviderInstall}`);
3419
+ }
3420
+ const noProviderBashrc = path.join(noProviderHome, '.bashrc');
3421
+ if (fs.existsSync(noProviderBashrc) && fs.readFileSync(noProviderBashrc, 'utf8').includes('.hcc-shims')) {
3422
+ fail(`shim install without providers modified shell PATH:\n${fs.readFileSync(noProviderBashrc, 'utf8')}`);
3423
+ }
3424
+ } finally {
3425
+ try { fs.rmSync(noProviderHome, { recursive: true, force: true }); } catch {}
3426
+ }
3427
+ const fishHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-fish-home-'));
3428
+ try {
3429
+ const fishEnv = {
3430
+ ...env,
3431
+ HOME: fishHome,
3432
+ SHELL: '/usr/bin/fish'
3433
+ };
3434
+ const fishInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: fishEnv });
3435
+ const fishRc = path.join(fishHome, '.config', 'fish', 'config.fish');
3436
+ if (!fishInstall.includes('PATH updated') ||
3437
+ !fs.existsSync(fishRc) ||
3438
+ !fs.readFileSync(fishRc, 'utf8').includes('# hello-cc shims (final)')) {
3439
+ fail(`fish shim install did not create config.fish PATH entry:\n${fishInstall}\n${fs.existsSync(fishRc) ? fs.readFileSync(fishRc, 'utf8') : '(missing)'}`);
3440
+ }
3441
+ } finally {
3442
+ try { fs.rmSync(fishHome, { recursive: true, force: true }); } catch {}
3443
+ }
3444
+ const missingRcHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-missing-rc-home-'));
3445
+ try {
3446
+ const missingRcEnv = {
3447
+ ...env,
3448
+ HOME: missingRcHome,
3449
+ SHELL: '/bin/bash'
3450
+ };
3451
+ const uninstallOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'uninstall'], { env: missingRcEnv });
3452
+ if (uninstallOutput.includes('PATH entry not removed') || !uninstallOutput.includes('PATH entry not present')) {
3453
+ fail(`shim uninstall reported missing shell rc as a failure:\n${uninstallOutput}`);
3454
+ }
3455
+ } finally {
3456
+ try { fs.rmSync(missingRcHome, { recursive: true, force: true }); } catch {}
3457
+ }
3458
+ const partialStatusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-status-home-'));
3459
+ try {
3460
+ const partialStatusDir = path.join(partialStatusHome, '.hcc-shims');
3461
+ fs.mkdirSync(partialStatusDir, { recursive: true });
3462
+ fs.writeFileSync(path.join(partialStatusDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
3463
+ const partialStatusEnv = {
3464
+ ...env,
3465
+ HOME: partialStatusHome,
3466
+ SHELL: '/bin/bash'
3467
+ };
3468
+ const statusOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'status'], { env: partialStatusEnv });
3469
+ if (!statusOutput.includes('claude: installed') ||
3470
+ !statusOutput.includes('codex: missing') ||
3471
+ !statusOutput.includes('status: partial')) {
3472
+ fail(`shim status did not report partial install per tool:\n${statusOutput}`);
3473
+ }
3474
+ } finally {
3475
+ try { fs.rmSync(partialStatusHome, { recursive: true, force: true }); } catch {}
3476
+ }
3477
+ const partialShimHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-home-'));
3478
+ const partialShimRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-root-'));
3479
+ try {
3480
+ const partialShimDir = path.join(partialShimHome, '.hcc-shims');
3481
+ fs.mkdirSync(partialShimDir, { recursive: true });
3482
+ fs.writeFileSync(path.join(partialShimDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
3483
+ const partialShimEnv = {
3484
+ ...env,
3485
+ HOME: partialShimHome,
3486
+ SHELL: '/bin/bash'
3487
+ };
3488
+ run(process.execPath, [hccBin, '--root', partialShimRoot, 'setup', '--quiet'], { env: partialShimEnv });
3489
+ for (const name of ['claude', 'codex']) {
3490
+ if (!fs.existsSync(path.join(partialShimDir, name))) {
3491
+ fail(`setup did not repair partial shim install; missing ${name}`);
3492
+ }
3493
+ }
3494
+ } finally {
3495
+ try { fs.rmSync(partialShimHome, { recursive: true, force: true }); } catch {}
3496
+ try { fs.rmSync(partialShimRoot, { recursive: true, force: true }); } catch {}
3497
+ }
3498
+ const scanHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-home-'));
3499
+ const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-root-'));
3500
+ try {
3501
+ const scanRealRoot = fs.realpathSync(scanRoot);
3502
+ const claudeSessionsDir = path.join(scanHome, '.claude', 'sessions');
3503
+ fs.mkdirSync(claudeSessionsDir, { recursive: true });
3504
+ fs.writeFileSync(path.join(claudeSessionsDir, 'scan-realpath.json'), JSON.stringify({
3505
+ pid: process.pid,
3506
+ sessionId: 'scan-realpath-session',
3507
+ cwd: scanRoot,
3508
+ status: 'running'
3509
+ }));
3510
+ const scanEnv = {
3511
+ ...env,
3512
+ HOME: scanHome
3513
+ };
3514
+ const scanOutput = run(process.execPath, [hccBin, '--root', scanRealRoot, 'scan'], { env: scanEnv });
3515
+ if (!scanOutput.includes('claude') || !scanOutput.includes('scan-realpath')) {
3516
+ fail(`scan did not match discovered hccRoot through realpath comparison:\n${scanOutput}`);
3517
+ }
3518
+ } finally {
3519
+ try { fs.rmSync(scanHome, { recursive: true, force: true }); } catch {}
3520
+ try { fs.rmSync(scanRoot, { recursive: true, force: true }); } catch {}
3521
+ }
3201
3522
  const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
3202
3523
  for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
3203
3524
  if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
@@ -3588,11 +3909,12 @@ async function syntaxAndHelp() {
3588
3909
  events: [
3589
3910
  { id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
3590
3911
  { id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 },
3591
- { id: 9, type: 'web.session.stop_requested', actor: 'web', task_id: null, payload: JSON.stringify({ actor_peer: 'web', target_peer: 'peer-b', peer: 'peer-b' }), created_at: 9 }
3912
+ { id: 9, type: 'web.session.stop_requested', actor: 'web', task_id: null, payload: JSON.stringify({ actor_peer: 'web', target_peer: 'peer-b', peer: 'peer-b' }), created_at: 9 },
3913
+ { id: 10, type: 'task.dispatched', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ actor_peer: 'peer-a', target_peer: 'peer-b', peer: 'peer-b', title: 'dispatch timeline' }), created_at: 10 }
3592
3914
  ]
3593
3915
  }, 'peer-b');
3594
3916
  const timelineIds = timelineItems.map((item) => item.id);
3595
- if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9'])) {
3917
+ if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9', 'event:10'])) {
3596
3918
  fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
3597
3919
  }
3598
3920
  const inbound = timelineItems.find((item) => item.id === 'message:2');
@@ -5143,12 +5465,25 @@ async function syntaxAndHelp() {
5143
5465
  if (!taskHelp.includes('task next [--peer ID] [--force]') ||
5144
5466
  !taskHelp.includes('[--count N]') ||
5145
5467
  !taskHelp.includes('existing claimed/running/review/blocked task') ||
5468
+ !taskHelp.includes('task dispatch --to ID --title TEXT') ||
5469
+ !taskHelp.includes('task dispatch --to ID --id N') ||
5470
+ !taskHelp.includes('message-only') ||
5471
+ !taskHelp.includes('running managed Claude/Codex terminal') ||
5146
5472
  !taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
5147
5473
  !taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
5148
5474
  !taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
5149
5475
  !taskHelp.includes('task create --title TEXT --parent N')) {
5150
5476
  fail(`task help missing current-task task next semantics:\n${taskHelp}`);
5151
5477
  }
5478
+ for (const expected of [
5479
+ "if (sub === 'dispatch') return taskDispatch",
5480
+ 'async function taskDispatch(ctx, args)',
5481
+ "addEvent(eventDb, 'task.dispatched'",
5482
+ 'function sessionLooksProviderInteractive(session)',
5483
+ '!customMessage && !sessionLooksProviderInteractive(session)'
5484
+ ]) {
5485
+ if (!hccSource.includes(expected)) fail(`task dispatch source guard missing: ${expected}`);
5486
+ }
5152
5487
  const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
5153
5488
  if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
5154
5489
  fail(`team help missing expected content:\n${teamHelp}`);
@@ -5177,6 +5512,19 @@ async function syntaxAndHelp() {
5177
5512
  !tmuxHelp.includes('deletion requires --yes')) {
5178
5513
  fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
5179
5514
  }
5515
+ const shimHelp = run(process.execPath, [hccBin, 'shim', '--help']);
5516
+ if (!shimHelp.includes('hcc shim') ||
5517
+ !shimHelp.includes('shim install') ||
5518
+ !shimHelp.includes('shim status') ||
5519
+ !shimHelp.includes('shell PATH entry')) {
5520
+ fail(`shim help missing maintenance command content:\n${shimHelp}`);
5521
+ }
5522
+ const installHooksHelp = run(process.execPath, [hccBin, 'install-hooks', '--help']);
5523
+ if (!installHooksHelp.includes('hcc install-hooks') ||
5524
+ !installHooksHelp.includes('install-hooks --status') ||
5525
+ !installHooksHelp.includes('install-hooks --uninstall')) {
5526
+ fail(`install-hooks help missing maintenance command content:\n${installHooksHelp}`);
5527
+ }
5180
5528
  const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
5181
5529
  if (!gcHelp.includes('hcc gc') ||
5182
5530
  !gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
@@ -5246,11 +5594,16 @@ function uninstallWorkflow() {
5246
5594
  ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
5247
5595
  ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
5248
5596
  ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
5597
+ const uninstallBashrc = path.join(uninstallHome, '.bashrc');
5598
+ if (!fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) {
5599
+ fail('setup did not install shim PATH entry before uninstall workflow');
5600
+ }
5249
5601
 
5250
5602
  const kept = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall'], { env: uninstallEnv });
5251
5603
  if (!kept.includes('project data kept')) fail(`uninstall did not keep project data by default:\n${kept}`);
5252
5604
  if (!fs.existsSync(path.join(uninstallRoot, '.hello-cc', 'mesh.db'))) fail('default uninstall removed project db');
5253
5605
  if (fs.existsSync(path.join(uninstallHome, '.hcc-shims', 'claude'))) fail('default uninstall did not remove shim');
5606
+ if (fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) fail('default uninstall did not remove shim PATH entry');
5254
5607
  if (!run(process.execPath, [hccBin, '--root', uninstallRoot, 'install-hooks', '--status'], { env: uninstallEnv }).includes('claude=no codex=no')) {
5255
5608
  fail('default uninstall did not remove hooks');
5256
5609
  }