@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.
@@ -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;
@@ -1150,6 +1216,197 @@ function libModuleFiles() {
1150
1216
  .map((name) => path.join('lib', name));
1151
1217
  }
1152
1218
 
1219
+ function assertShimRuntimeUnavailableFallback(generateShim) {
1220
+ const fallbackDir = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-shim-fallback-${testId}-`));
1221
+ try {
1222
+ const fakeHcc = path.join(fallbackDir, 'hcc');
1223
+ fs.writeFileSync(fakeHcc, `#!/usr/bin/env bash
1224
+ case "\${1:-}" in
1225
+ shim)
1226
+ exit 64
1227
+ ;;
1228
+ find-root)
1229
+ if [ -n "\${HCC_FAKE_ROOT:-}" ]; then
1230
+ printf '%s\\n' "$HCC_FAKE_ROOT"
1231
+ exit 0
1232
+ fi
1233
+ exit 1
1234
+ ;;
1235
+ web)
1236
+ exit 1
1237
+ ;;
1238
+ peer)
1239
+ if [ "\${2:-}" = "start" ]; then
1240
+ printf '%s\\n' "hcc: No running web runtime found. Start hcc web first, then hcc peer start \${3:-peer}" >&2
1241
+ exit 1
1242
+ fi
1243
+ exit 1
1244
+ ;;
1245
+ esac
1246
+ exit 1
1247
+ `, { mode: 0o755 });
1248
+
1249
+ const cases = [
1250
+ {
1251
+ tool: { name: 'claude', kind: 'claude', resumeFlag: '--resume' },
1252
+ args: ['--resume', 'missing-web-session'],
1253
+ expected: 'real-claude --resume missing-web-session'
1254
+ },
1255
+ {
1256
+ tool: { name: 'codex', kind: 'codex' },
1257
+ args: ['resume', 'missing-web-session'],
1258
+ expected: 'real-codex resume missing-web-session'
1259
+ }
1260
+ ];
1261
+
1262
+ for (const entry of cases) {
1263
+ const realBin = path.join(fallbackDir, `real-${entry.tool.name}`);
1264
+ const shimBin = path.join(fallbackDir, entry.tool.name);
1265
+ const logFile = path.join(fallbackDir, `${entry.tool.name}.log`);
1266
+ fs.writeFileSync(realBin, `#!/usr/bin/env bash
1267
+ printf 'real-${entry.tool.name}'
1268
+ for arg in "$@"; do printf ' %s' "$arg"; done
1269
+ printf '\\n'
1270
+ if [ -n "\${HCC_FAKE_LOG:-}" ]; then
1271
+ printf 'real-${entry.tool.name}' >> "$HCC_FAKE_LOG"
1272
+ for arg in "$@"; do printf ' %s' "$arg" >> "$HCC_FAKE_LOG"; done
1273
+ printf '\\n' >> "$HCC_FAKE_LOG"
1274
+ fi
1275
+ `, { mode: 0o755 });
1276
+ fs.writeFileSync(shimBin, generateShim(fakeHcc, realBin, entry.tool), { mode: 0o755 });
1277
+
1278
+ const result = runMaybe(shimBin, entry.args, {
1279
+ cwd: fallbackDir,
1280
+ env: {
1281
+ ...env,
1282
+ HCC_FAKE_ROOT: fallbackDir,
1283
+ HCC_FAKE_LOG: logFile
1284
+ }
1285
+ });
1286
+ if (result.status !== 0) {
1287
+ fail(`${entry.tool.name} shim did not fall back when runtime was unavailable:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1288
+ }
1289
+ if (!String(result.stdout || '').includes(entry.expected)) {
1290
+ fail(`${entry.tool.name} shim fallback did not launch the real provider:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1291
+ }
1292
+ const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
1293
+ if (!logOutput.includes(entry.expected)) {
1294
+ fail(`${entry.tool.name} shim fallback did not preserve provider argv:\n${logOutput}`);
1295
+ }
1296
+ if (String(result.stderr || '').includes('No running web runtime found')) {
1297
+ fail(`${entry.tool.name} shim leaked hcc runtime failure instead of transparent fallback:\n${result.stderr}`);
1298
+ }
1299
+ }
1300
+ } finally {
1301
+ try { fs.rmSync(fallbackDir, { recursive: true, force: true }); } catch {}
1302
+ }
1303
+ }
1304
+
1305
+ async function assertShimIgnoresGlobalRuntime(generateShim) {
1306
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-shim-global-runtime-${testId}-`));
1307
+ const fakeHome = path.join(testDir, 'home');
1308
+ const projectRoot = path.join(testDir, 'project-without-runtime');
1309
+ const requests = [];
1310
+ let server = null;
1311
+ try {
1312
+ fs.mkdirSync(path.join(fakeHome, '.hello-cc'), { recursive: true });
1313
+ fs.mkdirSync(projectRoot, { recursive: true });
1314
+
1315
+ server = http.createServer((req, res) => {
1316
+ requests.push({ method: req.method, url: req.url });
1317
+ res.setHeader('Content-Type', 'application/json');
1318
+ if (req.method === 'GET' && req.url.startsWith('/api/runtime')) {
1319
+ res.end(JSON.stringify({ ok: true }));
1320
+ return;
1321
+ }
1322
+ if (req.method === 'POST' && req.url.startsWith('/api/sessions')) {
1323
+ res.end(JSON.stringify({ session: {
1324
+ id: 'global-runtime-session',
1325
+ kind: 'claude',
1326
+ role: 'peer',
1327
+ status: 'running',
1328
+ pane: '%900'
1329
+ } }));
1330
+ return;
1331
+ }
1332
+ res.statusCode = 404;
1333
+ res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));
1334
+ });
1335
+ await new Promise((resolve, reject) => {
1336
+ server.once('error', reject);
1337
+ server.listen(0, '127.0.0.1', resolve);
1338
+ });
1339
+ const address = server.address();
1340
+ const globalRuntime = {
1341
+ product: 'hello-cc',
1342
+ version: 'regression',
1343
+ pid: process.pid,
1344
+ root: testDir,
1345
+ db: path.join(testDir, '.hello-cc', 'mesh.db'),
1346
+ host: '127.0.0.1',
1347
+ port: address.port,
1348
+ base_url: `http://127.0.0.1:${address.port}`,
1349
+ token: '',
1350
+ started_at: Math.floor(Date.now() / 1000),
1351
+ global_runtime: true
1352
+ };
1353
+ fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2));
1354
+
1355
+ const hccWrapper = path.join(testDir, 'hcc-wrapper');
1356
+ fs.writeFileSync(hccWrapper, `#!/usr/bin/env bash\nexec ${sh(process.execPath)} ${sh(hccBin)} "$@"\n`, { mode: 0o755 });
1357
+
1358
+ const cases = [
1359
+ {
1360
+ tool: { name: 'claude', kind: 'claude', resumeFlag: '--resume' },
1361
+ args: ['--resume', 'global-runtime-session'],
1362
+ expected: 'real-claude --resume global-runtime-session'
1363
+ },
1364
+ {
1365
+ tool: { name: 'codex', kind: 'codex' },
1366
+ args: ['resume', 'global-runtime-session'],
1367
+ expected: 'real-codex resume global-runtime-session'
1368
+ }
1369
+ ];
1370
+
1371
+ for (const entry of cases) {
1372
+ requests.length = 0;
1373
+ fs.rmSync(path.join(projectRoot, '.hello-cc'), { recursive: true, force: true });
1374
+ const realBin = path.join(testDir, `real-${entry.tool.name}`);
1375
+ const shimBin = path.join(testDir, entry.tool.name);
1376
+ fs.writeFileSync(realBin, `#!/usr/bin/env bash
1377
+ printf 'real-${entry.tool.name}'
1378
+ for arg in "$@"; do printf ' %s' "$arg"; done
1379
+ printf '\\n'
1380
+ `, { mode: 0o755 });
1381
+ fs.writeFileSync(shimBin, generateShim(hccWrapper, realBin, entry.tool), { mode: 0o755 });
1382
+ const result = runMaybe(shimBin, entry.args, {
1383
+ cwd: projectRoot,
1384
+ env: {
1385
+ ...env,
1386
+ HOME: fakeHome,
1387
+ HCC_SHIM_ENSURED: '1',
1388
+ HCC_SHIM_NO_ATTACH: '1'
1389
+ }
1390
+ });
1391
+ if (result.status !== 0) {
1392
+ fail(`${entry.tool.name} shim failed while ignoring global runtime:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1393
+ }
1394
+ if (!String(result.stdout || '').includes(entry.expected)) {
1395
+ fail(`${entry.tool.name} shim used global runtime instead of falling back:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1396
+ }
1397
+ if (requests.length) {
1398
+ fail(`${entry.tool.name} shim contacted global runtime despite project-local restriction:\n${JSON.stringify(requests, null, 2)}`);
1399
+ }
1400
+ if (fs.existsSync(path.join(projectRoot, '.hello-cc', 'mesh.db'))) {
1401
+ fail(`${entry.tool.name} shim created a project database while only a global runtime existed`);
1402
+ }
1403
+ }
1404
+ } finally {
1405
+ if (server?.listening) await new Promise((resolve) => server.close(resolve));
1406
+ try { fs.rmSync(testDir, { recursive: true, force: true }); } catch {}
1407
+ }
1408
+ }
1409
+
1153
1410
  async function expectBoundedTmuxStream(label) {
1154
1411
  let nodes = [];
1155
1412
  await waitFor(() => {
@@ -1194,19 +1451,54 @@ function startRuntime(options = {}) {
1194
1451
  if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
1195
1452
  }
1196
1453
 
1454
+ async function assertWebWrapperParentSurvives() {
1455
+ const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
1456
+ const wrapperPort = port + 101;
1457
+ let wrapperPid = null;
1458
+ try {
1459
+ const script = [
1460
+ 'set -e',
1461
+ `out=$(${sh(process.execPath)} ${sh(hccBin)} --root ${sh(wrapperRoot)} web --local --port ${wrapperPort} --no-discover --no-guidance)`,
1462
+ `printf '%s\\n' "$out"`
1463
+ ].join('\n');
1464
+ const result = runMaybe('bash', ['-lc', script], { env });
1465
+ if (result.status !== 0) {
1466
+ fail(`hcc web killed or failed inside a wrapper shell:\nstdout=${result.stdout}\nstderr=${result.stderr}\nstatus=${result.status}\nsignal=${result.signal}`);
1467
+ }
1468
+ const match = String(result.stdout || '').match(/^pid:\s*(\d+)/m);
1469
+ if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
1470
+ wrapperPid = Number.parseInt(match[1], 10);
1471
+ ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
1472
+ } finally {
1473
+ runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
1474
+ if (wrapperPid) {
1475
+ try {
1476
+ await waitForProcessExit(wrapperPid, 'wrapper runtime process exit', 5000);
1477
+ } catch {
1478
+ try { process.kill(wrapperPid, 'SIGTERM'); } catch {}
1479
+ try { await waitForProcessExit(wrapperPid, 'wrapper runtime process exit after SIGTERM', 2000); } catch {}
1480
+ }
1481
+ }
1482
+ try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
1483
+ }
1484
+ }
1485
+
1197
1486
  async function stopRuntime() {
1198
1487
  if (!runtimePid) return;
1488
+ const pid = runtimePid;
1199
1489
  hccMaybe(['down']);
1200
- await waitFor(() => {
1490
+ try {
1491
+ await waitForProcessExit(pid, 'runtime process exit', 5000);
1492
+ } catch (err) {
1493
+ try { process.kill(pid, 'SIGTERM'); } catch {}
1201
1494
  try {
1202
- process.kill(runtimePid, 0);
1203
- return false;
1495
+ await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
1204
1496
  } catch {
1205
- return true;
1497
+ try { process.kill(pid, 'SIGKILL'); } catch {}
1498
+ try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
1499
+ throw err;
1206
1500
  }
1207
- }, 'runtime process exit', 5000).catch(() => {
1208
- try { process.kill(runtimePid, 'SIGTERM'); } catch {}
1209
- });
1501
+ }
1210
1502
  runtimePid = null;
1211
1503
  }
1212
1504
 
@@ -1315,7 +1607,12 @@ async function setupRegression() {
1315
1607
  fail(`interactive bash did not keep codex shim first: ${interactiveCodex}`);
1316
1608
  }
1317
1609
  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');
1610
+ const shimStatusOutput = hcc(['shim', 'status']);
1611
+ if (!shimStatusOutput.includes('claude: installed') ||
1612
+ !shimStatusOutput.includes('codex: installed') ||
1613
+ !shimStatusOutput.includes('status: complete')) {
1614
+ fail(`shims not fully installed:\n${shimStatusOutput}`);
1615
+ }
1319
1616
  const staleShim = path.join(home, '.hcc-shims', 'claude');
1320
1617
  fs.writeFileSync(staleShim, '#!/usr/bin/env bash\necho stale --web-managed\n', { mode: 0o755 });
1321
1618
  const ensured = hccMaybe(['shim', 'ensure', 'claude', staleShim]);
@@ -1326,7 +1623,27 @@ async function setupRegression() {
1326
1623
  if (!refreshedShim.includes('shim ensure "claude"') || refreshedShim.includes('stale --web-managed')) {
1327
1624
  fail(`shim ensure did not refresh stale shim:\n${refreshedShim}`);
1328
1625
  }
1626
+ const deadRealBin = path.join(outDir, 'removed-claude');
1627
+ fs.rmSync(deadRealBin, { force: true });
1628
+ fs.writeFileSync(staleShim, [
1629
+ '#!/usr/bin/env bash',
1630
+ '# hello-cc shim for claude (auto-generated; do not edit manually)',
1631
+ `# Real binary: ${deadRealBin}`,
1632
+ `REAL_BIN=${sh(deadRealBin)}`,
1633
+ 'exec "$REAL_BIN" "$@"',
1634
+ ''
1635
+ ].join('\n'), { mode: 0o755 });
1636
+ const rediscovered = hccMaybe(['shim', 'ensure', 'claude', staleShim, deadRealBin]);
1637
+ if (rediscovered.status !== 75) {
1638
+ fail(`shim ensure did not request re-exec for stale real binary:\n${rediscovered.stdout}\n${rediscovered.stderr}`);
1639
+ }
1640
+ const rediscoveredShim = fs.readFileSync(staleShim, 'utf8');
1641
+ const expectedRealBin = path.join(fakeBin, 'claude');
1642
+ if (!rediscoveredShim.includes(`# Real binary: ${expectedRealBin}`) || rediscoveredShim.includes(deadRealBin)) {
1643
+ fail(`shim ensure preserved stale real binary instead of rediscovering PATH binary:\n${rediscoveredShim}`);
1644
+ }
1329
1645
  await stopRuntime();
1646
+ await assertWebWrapperParentSurvives();
1330
1647
  assertPeerBindingUniqueConstraints();
1331
1648
  assertLegacySchemaMigration();
1332
1649
  assertRegisteredProjectDbMigration();
@@ -1349,6 +1666,18 @@ async function setupRegression() {
1349
1666
  if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
1350
1667
  const tokenResponse = await runtimeFetch('/api/runtime');
1351
1668
  if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
1669
+ const badJsonResponse = await runtimeFetch('/api/projects', {
1670
+ method: 'POST',
1671
+ headers: { 'Content-Type': 'application/json' },
1672
+ body: '{bad json'
1673
+ });
1674
+ if (badJsonResponse.status !== 400) {
1675
+ fail(`bad JSON request returned ${badJsonResponse.status}, expected 400:\n${await badJsonResponse.text()}`);
1676
+ }
1677
+ const badJsonBody = await badJsonResponse.json();
1678
+ if (badJsonBody?.error?.code !== 'BAD_REQUEST') {
1679
+ fail(`bad JSON request did not return BAD_REQUEST:\n${JSON.stringify(badJsonBody, null, 2)}`);
1680
+ }
1352
1681
  const tokenFile = path.join(home, '.hello-cc', 'web-token');
1353
1682
  ensureFile(tokenFile, tokenRuntime.token);
1354
1683
  fs.rmSync(tokenFile, { force: true });
@@ -1417,16 +1746,18 @@ async function setupRegression() {
1417
1746
  const childDir = path.join(root, 'packages', 'child');
1418
1747
  fs.mkdirSync(childDir, { recursive: true });
1419
1748
  const childFindRoot = hccFrom(['find-root'], childDir).trim();
1420
- if (childFindRoot !== childDir) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1749
+ if (!samePath(childFindRoot, childDir)) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1421
1750
  const childStatus = hccFrom(['status'], childDir);
1422
- if (!childStatus.includes(`root: ${childDir}`) || !childStatus.includes(`db: ${path.join(childDir, '.hello-cc', 'mesh.db')}`)) {
1751
+ if (!samePath(statusValue(childStatus, 'root'), childDir) ||
1752
+ !samePath(statusValue(childStatus, 'db'), path.join(childDir, '.hello-cc', 'mesh.db'))) {
1423
1753
  fail(`child command did not stay on current path:\n${childStatus}`);
1424
1754
  }
1425
1755
  ensureFile(path.join(childDir, '.hello-cc', 'mesh.db'));
1426
1756
  const explicitChildRoot = hccFrom(['--root', root, 'find-root'], childDir).trim();
1427
- if (explicitChildRoot !== root) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1757
+ if (!samePath(explicitChildRoot, root)) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1428
1758
  const explicitChildStatus = hccFrom(['--root', root, 'status'], childDir);
1429
- if (!explicitChildStatus.includes(`root: ${root}`) || !explicitChildStatus.includes(`db: ${path.join(root, '.hello-cc', 'mesh.db')}`)) {
1759
+ if (!samePath(statusValue(explicitChildStatus, 'root'), root) ||
1760
+ !samePath(statusValue(explicitChildStatus, 'db'), path.join(root, '.hello-cc', 'mesh.db'))) {
1430
1761
  fail(`explicit child command did not use requested root:\n${explicitChildStatus}`);
1431
1762
  }
1432
1763
  const childHookPayload = JSON.stringify({ session_id: 'child-hook-session', cwd: childDir, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
@@ -1565,6 +1896,38 @@ async function dbWorkflow() {
1565
1896
  hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
1566
1897
  }
1567
1898
  hcc(['task', 'update', '--peer', 'human', '--id', batchNextIds[2], '--status', 'abandoned', '--summary', 'batch next leftover cleanup']);
1899
+
1900
+ const dispatched = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-a', '--title', 'dispatch new task', '--body', 'dispatch body', '--no-inject']);
1901
+ if (!dispatched.task || dispatched.task.assignee !== 'dispatch-a' || dispatched.task.owner ||
1902
+ dispatched.injected !== false || dispatched.injection_reason !== 'no_inject' || !dispatched.message_id) {
1903
+ fail(`task dispatch did not create message-only assigned task:\n${JSON.stringify(dispatched, null, 2)}`);
1904
+ }
1905
+ const dispatchInbox = hcc(['msg', 'inbox', '--peer', 'dispatch-a', '--all']);
1906
+ if (!dispatchInbox.includes(`task #${dispatched.task.id}`) || !dispatchInbox.includes('dispatch new task')) {
1907
+ fail(`task dispatch durable message missing:\n${dispatchInbox}`);
1908
+ }
1909
+ const dispatchAudit = eventPayloads('task.dispatched', 20)
1910
+ .find((event) => Number(event.payload?.message_id) === Number(dispatched.message_id));
1911
+ if (!dispatchAudit ||
1912
+ dispatchAudit.actor !== 'human' ||
1913
+ dispatchAudit.payload.actor_peer !== 'human' ||
1914
+ dispatchAudit.payload.target_peer !== 'dispatch-a' ||
1915
+ dispatchAudit.payload.source !== 'cli' ||
1916
+ dispatchAudit.payload.injected !== false ||
1917
+ dispatchAudit.payload.injection_reason !== 'no_inject') {
1918
+ fail(`task dispatch audit payload wrong:\n${JSON.stringify(dispatchAudit, null, 2)}`);
1919
+ }
1920
+ const dispatchExistingCreated = hcc(['task', 'create', '--from', 'human', '--title', 'dispatch existing task']);
1921
+ const dispatchExistingMatch = dispatchExistingCreated.match(/created task #(\d+):/);
1922
+ if (!dispatchExistingMatch) fail(`cannot parse dispatch existing task id: ${dispatchExistingCreated}`);
1923
+ const dispatchExisting = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-b', '--id', dispatchExistingMatch[1], '--message', 'custom dispatch existing', '--no-inject']);
1924
+ if (String(dispatchExisting.task?.id) !== dispatchExistingMatch[1] ||
1925
+ dispatchExisting.task.assignee !== 'dispatch-b' ||
1926
+ dispatchExisting.message !== 'custom dispatch existing' ||
1927
+ dispatchExisting.injected !== false) {
1928
+ fail(`task dispatch --id did not assign existing task:\n${JSON.stringify(dispatchExisting, null, 2)}`);
1929
+ }
1930
+
1568
1931
  const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
1569
1932
  const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
1570
1933
  if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
@@ -1846,8 +2209,9 @@ async function multiProjectWebWorkflow() {
1846
2209
  fs.mkdirSync(otherRoot, { recursive: true });
1847
2210
  const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
1848
2211
  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}`);
2212
+ const outputRoot = statusValue(output, 'project');
2213
+ if (!samePath(outputRoot, otherRoot)) fail(`second project output did not show its root:\n${output}`);
2214
+ if (!output.includes(encodeURIComponent(outputRoot))) fail(`second project URL did not include project query:\n${output}`);
1851
2215
 
1852
2216
  const otherRuntimeFile = path.join(otherRoot, '.hello-cc', 'runtime.json');
1853
2217
  ensureFile(otherRuntimeFile);
@@ -1859,8 +2223,9 @@ async function multiProjectWebWorkflow() {
1859
2223
  const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
1860
2224
  const projects = await projectsResponse.json();
1861
2225
  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)) {
2226
+ const roots = (projects.projects || []).map((p) => p.root);
2227
+ if (!roots.some((projectRoot) => samePath(projectRoot, root)) ||
2228
+ !roots.some((projectRoot) => samePath(projectRoot, otherRoot))) {
1864
2229
  fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
1865
2230
  }
1866
2231
 
@@ -2122,6 +2487,10 @@ async function multiProjectWebWorkflow() {
2122
2487
  if (!managedActionSession.action_token) {
2123
2488
  fail(`managed web session did not include action token:\n${JSON.stringify(managedActionSession, null, 2)}`);
2124
2489
  }
2490
+ const runtimeActionToken = currentRuntime().token || '';
2491
+ if (managedActionSession.action_token === runtimeActionToken || managedActionSession.action_token.length < 32) {
2492
+ fail(`managed web session action token is not an independent session token:\n${JSON.stringify({ runtimeActionToken, managedActionSession }, null, 2)}`);
2493
+ }
2125
2494
  const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
2126
2495
  method: 'POST',
2127
2496
  headers: { 'Content-Type': 'application/json' },
@@ -2305,11 +2674,18 @@ async function tmuxBackedStartWorkflow() {
2305
2674
  await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
2306
2675
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
2307
2676
 
2308
- await stopRuntime();
2677
+ const heldWs = await openTerminalWebSocket('shell-a');
2678
+ try {
2679
+ await stopRuntime();
2680
+ await waitFor(() => heldWs.readyState === WebSocket.CLOSED || heldWs.readyState === WebSocket.CLOSING, 'runtime websocket close', 2000);
2681
+ } finally {
2682
+ try { heldWs.close(); } catch {}
2683
+ }
2309
2684
  run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
2310
2685
  startRuntime();
2311
2686
  await waitRuntime();
2312
- await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore');
2687
+ await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore after websocket shutdown');
2688
+
2313
2689
  const restoredFile = path.join(outDir, 'pty-restored-ok');
2314
2690
  hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
2315
2691
  await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
@@ -2401,6 +2777,10 @@ async function shimTmuxWorkflow() {
2401
2777
  }
2402
2778
 
2403
2779
  log('[6/13] shim tmux-backed launch');
2780
+ if (!fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) {
2781
+ startRuntime();
2782
+ }
2783
+ await waitRuntime();
2404
2784
  const shim = path.join(home, '.hcc-shims', 'claude');
2405
2785
  const codexShim = path.join(home, '.hcc-shims', 'codex');
2406
2786
  const claudeVersion = run(shim, ['--version'], { cwd: root, env });
@@ -2471,6 +2851,9 @@ async function shimTmuxWorkflow() {
2471
2851
  const firstEnvFile = path.join(outDir, 'shim-env-first');
2472
2852
  hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
2473
2853
  await waitForFile(firstEnvFile, 'shim-first', 'shim first env');
2854
+ const shimInternalEnvFile = path.join(outDir, 'shim-internal-env');
2855
+ hcc(['inject', peer, `printf '%s|%s|%s\\n' "\${HCC_SHIM_ENSURED-unset}" "\${HCC_SKIP_SHIM_INSTALL-unset}" "\${HCC_RUNTIME_LOCAL_ONLY-unset}" > ${shimInternalEnvFile}`]);
2856
+ await waitForFile(shimInternalEnvFile, 'unset|unset|unset', 'shim internal env cleanup');
2474
2857
 
2475
2858
  const restarted = run(shim, ['--resume', 'shim-regression-session'], {
2476
2859
  cwd: root,
@@ -2492,12 +2875,34 @@ async function shimTmuxWorkflow() {
2492
2875
  HCC_FAKE_STAY_ALIVE: '0',
2493
2876
  HCC_FAKE_LOG: exitedLog
2494
2877
  };
2495
- const exitedFirst = run(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
2878
+ const exitedFirstResult = runMaybe(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
2879
+ if (exitedFirstResult.status !== 0) {
2880
+ const allShimLines = fs.readFileSync(shim, 'utf8').split('\n');
2881
+ const shimLines = allShimLines
2882
+ .slice(0, 35)
2883
+ .concat(['...'])
2884
+ .concat(allShimLines.slice(139))
2885
+ .map((line, index) => {
2886
+ if (index < 35) return `${index + 1}: ${line}`;
2887
+ if (index === 35) return line;
2888
+ return `${index + 104}: ${line}`;
2889
+ })
2890
+ .join('\n');
2891
+ fail(`${sh([shim, '--resume', exitedResume].join(' '))} failed\n${exitedFirstResult.stdout || ''}${exitedFirstResult.stderr || ''}\nshim excerpt:\n${shimLines}`);
2892
+ }
2893
+ const exitedFirst = exitedFirstResult.stdout || '';
2496
2894
  const exitedPeerMatch = exitedFirst.match(/started\s+(\S+)\s+\(/);
2497
2895
  if (!exitedPeerMatch) fail(`exited shim did not start a peer:\n${exitedFirst}`);
2498
2896
  const exitedPeer = exitedPeerMatch[1];
2499
- parsePane(exitedFirst);
2500
- await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
2897
+ const exitedPane = parsePane(exitedFirst);
2898
+ try {
2899
+ await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
2900
+ } catch (err) {
2901
+ const paneCapture = runMaybe('tmux', ['capture-pane', '-p', '-S', '-80', '-t', exitedPane]);
2902
+ const peerList = hccMaybe(['peer', 'list']);
2903
+ const shimHead = fs.readFileSync(shim, 'utf8').split('\n').slice(0, 18).join('\n');
2904
+ 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}`);
2905
+ }
2501
2906
  const fallbackFile = path.join(outDir, 'shim-exited-fallback');
2502
2907
  hcc(['inject', exitedPeer, `printf '%s\\n' fallback > ${sh(fallbackFile)}`]);
2503
2908
  await waitForFile(fallbackFile, 'fallback', 'shim exited fallback shell');
@@ -2546,6 +2951,16 @@ async function askBroadcastWorkflow() {
2546
2951
  hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
2547
2952
  await waitForFile(askFile, 'ASK_OK', 'ask injection');
2548
2953
  if (!hcc(['msg', 'inbox', '--peer', 'shell-b', '--all']).includes('ASK_OK')) fail('ask durable message missing');
2954
+ const shellDefaultDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell default dispatch']);
2955
+ if (shellDefaultDispatch.injected !== false || shellDefaultDispatch.injection_reason !== 'unsupported_session_kind') {
2956
+ fail(`task dispatch injected default natural-language prompt into shell session:\n${JSON.stringify(shellDefaultDispatch, null, 2)}`);
2957
+ }
2958
+ const dispatchFile = path.join(outDir, 'dispatch-ok');
2959
+ const shellCommandDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell command dispatch', '--message', `echo DISPATCH_OK > ${dispatchFile}`, '--force']);
2960
+ if (!shellCommandDispatch.injected || shellCommandDispatch.delivery !== 'message+inject') {
2961
+ fail(`task dispatch did not inject explicit shell-safe message:\n${JSON.stringify(shellCommandDispatch, null, 2)}`);
2962
+ }
2963
+ await waitForFile(dispatchFile, 'DISPATCH_OK', 'task dispatch injection');
2549
2964
  const broadcastFile = path.join(outDir, 'broadcast-ok');
2550
2965
  hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
2551
2966
  await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
@@ -2596,8 +3011,8 @@ async function downGcPackWorkflow() {
2596
3011
  "tags:\n - 'v*'",
2597
3012
  'workflow_dispatch:',
2598
3013
  'contents: write',
2599
- 'actions/checkout@v4',
2600
- 'actions/setup-node@v4',
3014
+ 'actions/checkout@v5',
3015
+ 'actions/setup-node@v5',
2601
3016
  'node-version: 24',
2602
3017
  'npm run release:check',
2603
3018
  'npm run release:github:dry-run',
@@ -2620,16 +3035,23 @@ function oldNameScan() {
2620
3035
  'ACTIVE_' + 'AGENT_' + 'TTL',
2621
3036
  '--' + 'agent\\b'
2622
3037
  ].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}`);
3038
+ const oldNameRegex = new RegExp(oldNamePattern);
3039
+ const files = run('git', ['ls-files'])
3040
+ .split('\n')
3041
+ .map((file) => file.trim())
3042
+ .filter(Boolean)
3043
+ .filter((file) => !file.includes('/node_modules/') && !file.endsWith('.tgz'));
3044
+ const matches = [];
3045
+ for (const file of files) {
3046
+ const fullPath = path.join(repoRoot, file);
3047
+ let content = '';
3048
+ try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
3049
+ const lines = content.split('\n');
3050
+ for (let i = 0; i < lines.length; i += 1) {
3051
+ if (oldNameRegex.test(lines[i])) matches.push(`${file}:${i + 1}: ${lines[i]}`);
3052
+ }
3053
+ }
3054
+ if (matches.length) fail(`old names found:\n${matches.join('\n')}`);
2633
3055
  }
2634
3056
 
2635
3057
  function identityEnforcementWorkflow() {
@@ -3132,6 +3554,7 @@ async function syntaxAndHelp() {
3132
3554
  'function installShims',
3133
3555
  'function uninstallShims',
3134
3556
  'function verifyShims',
3557
+ 'function shimStatus',
3135
3558
  'function findRealBinary',
3136
3559
  'function fsExists'
3137
3560
  ]) {
@@ -3159,9 +3582,14 @@ async function syntaxAndHelp() {
3159
3582
  }
3160
3583
  if (!integrationShimScriptSource.includes('function generateShim') ||
3161
3584
  !integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
3162
- !integrationShimScriptSource.includes('HCC_SHIM_ENSURED')) {
3585
+ !integrationShimScriptSource.includes('HCC_SHIM_ENSURED') ||
3586
+ !integrationShimScriptSource.includes('HCC_RUNTIME_LOCAL_ONLY') ||
3587
+ !integrationShimScriptSource.includes('hcc_runtime_unavailable_start_failure')) {
3163
3588
  fail('integrations shim script module is missing expected generateShim wiring');
3164
3589
  }
3590
+ if (integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web')) {
3591
+ fail('integration shim script still auto-starts hcc web instead of falling back to the real provider');
3592
+ }
3165
3593
  if (!shellPathSource.includes('function installPathEntry') ||
3166
3594
  !shellPathSource.includes('function uninstallPathEntry') ||
3167
3595
  !shellPathSource.includes('.hcc-shims')) {
@@ -3184,7 +3612,7 @@ async function syntaxAndHelp() {
3184
3612
  if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
3185
3613
  if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
3186
3614
  }
3187
- for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
3615
+ for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'shimStatus', 'findRealBinary']) {
3188
3616
  if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
3189
3617
  if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
3190
3618
  }
@@ -3194,10 +3622,153 @@ async function syntaxAndHelp() {
3194
3622
  if (setupModule.SHIM_DIR !== integrationShims.SHIM_DIR) fail('setup shim dir export mismatch');
3195
3623
  if (typeof integrationShimScript.generateShim !== 'function') fail('integrations shim script module missing generateShim export');
3196
3624
  if (shimScriptCompat.generateShim !== integrationShimScript.generateShim) fail('shim script compatibility export mismatch');
3625
+ assertShimRuntimeUnavailableFallback(integrationShimScript.generateShim);
3626
+ await assertShimIgnoresGlobalRuntime(integrationShimScript.generateShim);
3197
3627
  for (const name of ['installPathEntry', 'uninstallPathEntry']) {
3198
3628
  if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
3199
3629
  if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
3200
3630
  }
3631
+ const bashRc = '# regression rc\n[ -z "$PS1" ] && return\nexport PATH="/late:$PATH"\n';
3632
+ const bashPath = shellPath.insertPathEntry(bashRc, 'bash');
3633
+ if ((bashPath.match(/# hello-cc shims/g) || []).length !== 2 ||
3634
+ bashPath.indexOf('# hello-cc shims (early)') > bashPath.indexOf('[ -z "$PS1" ] && return') ||
3635
+ bashPath.lastIndexOf('# hello-cc shims (final)') < bashPath.indexOf('export PATH="/late:$PATH"')) {
3636
+ fail(`bash shell PATH insertion lost early/final placement:\n${bashPath}`);
3637
+ }
3638
+ const zshPath = shellPath.insertPathEntry('export PATH="$HOME/bin:$PATH"\n', 'zsh');
3639
+ if ((zshPath.match(/# hello-cc shims/g) || []).length !== 1 ||
3640
+ zshPath.includes('# hello-cc shims (early)') ||
3641
+ !zshPath.includes('# hello-cc shims (final)')) {
3642
+ fail(`zsh shell PATH insertion should install one final entry:\n${zshPath}`);
3643
+ }
3644
+ const fishPath = shellPath.insertPathEntry('set -gx PATH $HOME/bin $PATH\n', 'fish');
3645
+ if ((fishPath.match(/# hello-cc shims/g) || []).length !== 1 ||
3646
+ fishPath.includes('# hello-cc shims (early)') ||
3647
+ !fishPath.includes('# hello-cc shims (final)')) {
3648
+ fail(`fish shell PATH insertion should install one final entry:\n${fishPath}`);
3649
+ }
3650
+ const customRc = [
3651
+ 'export PATH="$HOME/.hcc-shims:$PATH" # user custom shim dir',
3652
+ shellPath.pathEntryLine('bash', 'final')
3653
+ ].join('\n');
3654
+ const cleanedRc = shellPath.removePathEntryLines(customRc);
3655
+ if (!cleanedRc.includes('user custom shim dir') || cleanedRc.includes('# hello-cc shims')) {
3656
+ fail(`shell PATH cleanup removed a non-hello-cc custom line or kept hello-cc line:\n${cleanedRc}`);
3657
+ }
3658
+ const noProviderHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-no-provider-home-'));
3659
+ try {
3660
+ const noProviderEnv = {
3661
+ ...env,
3662
+ HOME: noProviderHome,
3663
+ PATH: '/usr/bin:/bin',
3664
+ SHELL: '/bin/bash'
3665
+ };
3666
+ const noProviderInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: noProviderEnv });
3667
+ if (!noProviderInstall.includes('no shims installed')) {
3668
+ fail(`shim install without providers did not report no shims:\n${noProviderInstall}`);
3669
+ }
3670
+ const noProviderBashrc = path.join(noProviderHome, '.bashrc');
3671
+ if (fs.existsSync(noProviderBashrc) && fs.readFileSync(noProviderBashrc, 'utf8').includes('.hcc-shims')) {
3672
+ fail(`shim install without providers modified shell PATH:\n${fs.readFileSync(noProviderBashrc, 'utf8')}`);
3673
+ }
3674
+ } finally {
3675
+ try { fs.rmSync(noProviderHome, { recursive: true, force: true }); } catch {}
3676
+ }
3677
+ const fishHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-fish-home-'));
3678
+ try {
3679
+ const fishEnv = {
3680
+ ...env,
3681
+ HOME: fishHome,
3682
+ SHELL: '/usr/bin/fish'
3683
+ };
3684
+ const fishInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: fishEnv });
3685
+ const fishRc = path.join(fishHome, '.config', 'fish', 'config.fish');
3686
+ if (!fishInstall.includes('PATH updated') ||
3687
+ !fs.existsSync(fishRc) ||
3688
+ !fs.readFileSync(fishRc, 'utf8').includes('# hello-cc shims (final)')) {
3689
+ fail(`fish shim install did not create config.fish PATH entry:\n${fishInstall}\n${fs.existsSync(fishRc) ? fs.readFileSync(fishRc, 'utf8') : '(missing)'}`);
3690
+ }
3691
+ } finally {
3692
+ try { fs.rmSync(fishHome, { recursive: true, force: true }); } catch {}
3693
+ }
3694
+ const missingRcHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-missing-rc-home-'));
3695
+ try {
3696
+ const missingRcEnv = {
3697
+ ...env,
3698
+ HOME: missingRcHome,
3699
+ SHELL: '/bin/bash'
3700
+ };
3701
+ const uninstallOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'uninstall'], { env: missingRcEnv });
3702
+ if (uninstallOutput.includes('PATH entry not removed') || !uninstallOutput.includes('PATH entry not present')) {
3703
+ fail(`shim uninstall reported missing shell rc as a failure:\n${uninstallOutput}`);
3704
+ }
3705
+ } finally {
3706
+ try { fs.rmSync(missingRcHome, { recursive: true, force: true }); } catch {}
3707
+ }
3708
+ const partialStatusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-status-home-'));
3709
+ try {
3710
+ const partialStatusDir = path.join(partialStatusHome, '.hcc-shims');
3711
+ fs.mkdirSync(partialStatusDir, { recursive: true });
3712
+ fs.writeFileSync(path.join(partialStatusDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
3713
+ const partialStatusEnv = {
3714
+ ...env,
3715
+ HOME: partialStatusHome,
3716
+ SHELL: '/bin/bash'
3717
+ };
3718
+ const statusOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'status'], { env: partialStatusEnv });
3719
+ if (!statusOutput.includes('claude: installed') ||
3720
+ !statusOutput.includes('codex: missing') ||
3721
+ !statusOutput.includes('status: partial')) {
3722
+ fail(`shim status did not report partial install per tool:\n${statusOutput}`);
3723
+ }
3724
+ } finally {
3725
+ try { fs.rmSync(partialStatusHome, { recursive: true, force: true }); } catch {}
3726
+ }
3727
+ const partialShimHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-home-'));
3728
+ const partialShimRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-root-'));
3729
+ try {
3730
+ const partialShimDir = path.join(partialShimHome, '.hcc-shims');
3731
+ fs.mkdirSync(partialShimDir, { recursive: true });
3732
+ fs.writeFileSync(path.join(partialShimDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
3733
+ const partialShimEnv = {
3734
+ ...env,
3735
+ HOME: partialShimHome,
3736
+ SHELL: '/bin/bash'
3737
+ };
3738
+ run(process.execPath, [hccBin, '--root', partialShimRoot, 'setup', '--quiet'], { env: partialShimEnv });
3739
+ for (const name of ['claude', 'codex']) {
3740
+ if (!fs.existsSync(path.join(partialShimDir, name))) {
3741
+ fail(`setup did not repair partial shim install; missing ${name}`);
3742
+ }
3743
+ }
3744
+ } finally {
3745
+ try { fs.rmSync(partialShimHome, { recursive: true, force: true }); } catch {}
3746
+ try { fs.rmSync(partialShimRoot, { recursive: true, force: true }); } catch {}
3747
+ }
3748
+ const scanHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-home-'));
3749
+ const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-root-'));
3750
+ try {
3751
+ const scanRealRoot = fs.realpathSync(scanRoot);
3752
+ const claudeSessionsDir = path.join(scanHome, '.claude', 'sessions');
3753
+ fs.mkdirSync(claudeSessionsDir, { recursive: true });
3754
+ fs.writeFileSync(path.join(claudeSessionsDir, 'scan-realpath.json'), JSON.stringify({
3755
+ pid: process.pid,
3756
+ sessionId: 'scan-realpath-session',
3757
+ cwd: scanRoot,
3758
+ status: 'running'
3759
+ }));
3760
+ const scanEnv = {
3761
+ ...env,
3762
+ HOME: scanHome
3763
+ };
3764
+ const scanOutput = run(process.execPath, [hccBin, '--root', scanRealRoot, 'scan'], { env: scanEnv });
3765
+ if (!scanOutput.includes('claude') || !scanOutput.includes('scan-realpath')) {
3766
+ fail(`scan did not match discovered hccRoot through realpath comparison:\n${scanOutput}`);
3767
+ }
3768
+ } finally {
3769
+ try { fs.rmSync(scanHome, { recursive: true, force: true }); } catch {}
3770
+ try { fs.rmSync(scanRoot, { recursive: true, force: true }); } catch {}
3771
+ }
3201
3772
  const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
3202
3773
  for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
3203
3774
  if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
@@ -3588,11 +4159,12 @@ async function syntaxAndHelp() {
3588
4159
  events: [
3589
4160
  { id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
3590
4161
  { 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 }
4162
+ { 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 },
4163
+ { 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
4164
  ]
3593
4165
  }, 'peer-b');
3594
4166
  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'])) {
4167
+ if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9', 'event:10'])) {
3596
4168
  fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
3597
4169
  }
3598
4170
  const inbound = timelineItems.find((item) => item.id === 'message:2');
@@ -5143,12 +5715,25 @@ async function syntaxAndHelp() {
5143
5715
  if (!taskHelp.includes('task next [--peer ID] [--force]') ||
5144
5716
  !taskHelp.includes('[--count N]') ||
5145
5717
  !taskHelp.includes('existing claimed/running/review/blocked task') ||
5718
+ !taskHelp.includes('task dispatch --to ID --title TEXT') ||
5719
+ !taskHelp.includes('task dispatch --to ID --id N') ||
5720
+ !taskHelp.includes('message-only') ||
5721
+ !taskHelp.includes('running managed Claude/Codex terminal') ||
5146
5722
  !taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
5147
5723
  !taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
5148
5724
  !taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
5149
5725
  !taskHelp.includes('task create --title TEXT --parent N')) {
5150
5726
  fail(`task help missing current-task task next semantics:\n${taskHelp}`);
5151
5727
  }
5728
+ for (const expected of [
5729
+ "if (sub === 'dispatch') return taskDispatch",
5730
+ 'async function taskDispatch(ctx, args)',
5731
+ "addEvent(eventDb, 'task.dispatched'",
5732
+ 'function sessionLooksProviderInteractive(session)',
5733
+ '!customMessage && !sessionLooksProviderInteractive(session)'
5734
+ ]) {
5735
+ if (!hccSource.includes(expected)) fail(`task dispatch source guard missing: ${expected}`);
5736
+ }
5152
5737
  const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
5153
5738
  if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
5154
5739
  fail(`team help missing expected content:\n${teamHelp}`);
@@ -5177,6 +5762,19 @@ async function syntaxAndHelp() {
5177
5762
  !tmuxHelp.includes('deletion requires --yes')) {
5178
5763
  fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
5179
5764
  }
5765
+ const shimHelp = run(process.execPath, [hccBin, 'shim', '--help']);
5766
+ if (!shimHelp.includes('hcc shim') ||
5767
+ !shimHelp.includes('shim install') ||
5768
+ !shimHelp.includes('shim status') ||
5769
+ !shimHelp.includes('shell PATH entry')) {
5770
+ fail(`shim help missing maintenance command content:\n${shimHelp}`);
5771
+ }
5772
+ const installHooksHelp = run(process.execPath, [hccBin, 'install-hooks', '--help']);
5773
+ if (!installHooksHelp.includes('hcc install-hooks') ||
5774
+ !installHooksHelp.includes('install-hooks --status') ||
5775
+ !installHooksHelp.includes('install-hooks --uninstall')) {
5776
+ fail(`install-hooks help missing maintenance command content:\n${installHooksHelp}`);
5777
+ }
5180
5778
  const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
5181
5779
  if (!gcHelp.includes('hcc gc') ||
5182
5780
  !gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
@@ -5246,11 +5844,16 @@ function uninstallWorkflow() {
5246
5844
  ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
5247
5845
  ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
5248
5846
  ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
5847
+ const uninstallBashrc = path.join(uninstallHome, '.bashrc');
5848
+ if (!fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) {
5849
+ fail('setup did not install shim PATH entry before uninstall workflow');
5850
+ }
5249
5851
 
5250
5852
  const kept = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall'], { env: uninstallEnv });
5251
5853
  if (!kept.includes('project data kept')) fail(`uninstall did not keep project data by default:\n${kept}`);
5252
5854
  if (!fs.existsSync(path.join(uninstallRoot, '.hello-cc', 'mesh.db'))) fail('default uninstall removed project db');
5253
5855
  if (fs.existsSync(path.join(uninstallHome, '.hcc-shims', 'claude'))) fail('default uninstall did not remove shim');
5856
+ if (fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) fail('default uninstall did not remove shim PATH entry');
5254
5857
  if (!run(process.execPath, [hccBin, '--root', uninstallRoot, 'install-hooks', '--status'], { env: uninstallEnv }).includes('claude=no codex=no')) {
5255
5858
  fail('default uninstall did not remove hooks');
5256
5859
  }