@logicseek/hello-cc 0.1.6 → 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.
@@ -22,6 +22,9 @@ const fakeBin = path.join(os.tmpdir(), `hcc-reg-bin-${testId}`);
22
22
  const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
23
23
  const tmuxSession = `hcc-reg-${process.pid}`;
24
24
  const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
25
+ const secondProjectRoot = path.join(root, 'second-project');
26
+ const realHome = process.env.HOME || os.homedir();
27
+ const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
25
28
  const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
26
29
  encoding: 'utf8',
27
30
  stdio: ['ignore', 'pipe', 'ignore']
@@ -35,7 +38,8 @@ const managedTmuxSessions = new Set();
35
38
  const env = {
36
39
  ...process.env,
37
40
  HOME: home,
38
- PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`
41
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
42
+ SHELL: '/bin/bash'
39
43
  };
40
44
  for (const key of Object.keys(env)) {
41
45
  if (key.startsWith('HCC_')) delete env[key];
@@ -48,6 +52,13 @@ function log(message) {
48
52
  }
49
53
 
50
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
+ }
51
62
  throw new Error(message);
52
63
  }
53
64
 
@@ -59,6 +70,20 @@ function sh(value) {
59
70
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
60
71
  }
61
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
+
62
87
  function shortHash(value) {
63
88
  return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
64
89
  }
@@ -72,6 +97,10 @@ function tmuxManagedSession(projectRoot, peer) {
72
97
  return `hcc-${shortHash(projectRoot)}-${sanitizePeerPart(peer, 'peer')}`.slice(0, 80);
73
98
  }
74
99
 
100
+ function tmuxManagedSessionPrefix(projectRoot) {
101
+ return `hcc-${shortHash(projectRoot)}-`;
102
+ }
103
+
75
104
  function run(command, args, options = {}) {
76
105
  const result = spawnSync(command, args, {
77
106
  cwd: options.cwd || repoRoot,
@@ -140,6 +169,33 @@ function providerBindingRows(provider, sessionName) {
140
169
  `).all(provider, sessionName));
141
170
  }
142
171
 
172
+ function parsePayloadJson(value) {
173
+ try {
174
+ const parsed = JSON.parse(value || '{}');
175
+ return parsed && typeof parsed === 'object' ? parsed : {};
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
180
+
181
+ function eventPayloads(type, limit = 20, dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
182
+ const db = new DatabaseSync(dbPath);
183
+ try {
184
+ return db.prepare(`
185
+ SELECT actor, payload
186
+ FROM events
187
+ WHERE type = ?
188
+ ORDER BY id DESC
189
+ LIMIT ?
190
+ `).all(type, limit).map((row) => ({
191
+ actor: row.actor,
192
+ payload: parsePayloadJson(row.payload)
193
+ }));
194
+ } finally {
195
+ db.close();
196
+ }
197
+ }
198
+
143
199
  function peerBindingIndexNames(dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
144
200
  const db = new DatabaseSync(dbPath);
145
201
  try {
@@ -366,7 +422,8 @@ async function assertTmuxGcPolicy() {
366
422
  }), t);
367
423
  });
368
424
 
369
- 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)}`], {
370
427
  env,
371
428
  encoding: 'utf8',
372
429
  stdio: ['ignore', 'pipe', 'pipe']
@@ -773,6 +830,48 @@ function tmuxAvailable() {
773
830
  return runMaybe('tmux', ['-V']).status === 0;
774
831
  }
775
832
 
833
+ function tmuxCleanupEndpoints() {
834
+ if (!realTmuxBin) return [{ label: 'default', command: 'tmux', args: [] }];
835
+ return [
836
+ { label: 'test-socket', command: realTmuxBin, args: ['-L', tmuxSocketName] },
837
+ { label: 'default', command: realTmuxBin, args: [] }
838
+ ];
839
+ }
840
+
841
+ function runMaybeTmuxEndpoint(endpoint, args) {
842
+ return runMaybe(endpoint.command, [...endpoint.args, ...args]);
843
+ }
844
+
845
+ function listTmuxSessionsMatching(prefixes, endpoint) {
846
+ const output = runMaybeTmuxEndpoint(endpoint, ['list-sessions', '-F', '#{session_name}']);
847
+ if (output.status !== 0) return [];
848
+ return (output.stdout || '')
849
+ .split('\n')
850
+ .map((line) => line.trim())
851
+ .filter(Boolean)
852
+ .filter((session) => prefixes.some((prefix) => session.startsWith(prefix)));
853
+ }
854
+
855
+ function killTmuxSessionOnEndpoint(endpoint, session) {
856
+ runMaybeTmuxEndpoint(endpoint, ['kill-session', '-t', session]);
857
+ }
858
+
859
+ function assertNoRegressionTmuxSessionLeak() {
860
+ const prefixes = [
861
+ tmuxManagedSessionPrefix(root),
862
+ tmuxManagedSessionPrefix(secondProjectRoot)
863
+ ];
864
+ const leaks = [];
865
+ for (const endpoint of tmuxCleanupEndpoints()) {
866
+ for (const session of listTmuxSessionsMatching(prefixes, endpoint)) {
867
+ leaks.push(`${endpoint.label}:${session}`);
868
+ }
869
+ }
870
+ if (leaks.length) {
871
+ fail(`regression leaked hcc-managed tmux sessions:\n${leaks.join('\n')}`);
872
+ }
873
+ }
874
+
776
875
  function trackTmuxPane(pane) {
777
876
  if (!pane) return;
778
877
  const result = runMaybe('tmux', ['display-message', '-p', '-t', pane, '#S']);
@@ -947,19 +1046,34 @@ async function waitForFile(file, expected, label = file) {
947
1046
  }
948
1047
 
949
1048
  async function waitForFileContent(file, expected, label = file) {
950
- await waitFor(() => {
951
- if (!fs.existsSync(file)) return false;
952
- return fs.readFileSync(file, 'utf8').trim() === expected;
953
- }, 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
+ }
954
1061
  ensureFile(file, expected);
955
1062
  }
956
1063
 
957
1064
  async function waitForFileLineCount(file, expected, label = file) {
958
- await waitFor(() => {
959
- if (!fs.existsSync(file)) return false;
960
- const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
961
- return lines.length === expected;
962
- }, 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}`);
963
1077
  }
964
1078
 
965
1079
  async function waitRuntime() {
@@ -975,6 +1089,19 @@ async function waitRuntime() {
975
1089
  }, 'runtime');
976
1090
  }
977
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
+
978
1105
  async function expectWebSocketMarker(peer, marker) {
979
1106
  await new Promise((resolve, reject) => {
980
1107
  let sawSnapshot = false;
@@ -1001,6 +1128,21 @@ async function expectWebSocketMarker(peer, marker) {
1001
1128
  });
1002
1129
  }
1003
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
+
1004
1146
  async function expectResizeReplaceSnapshot(peer, marker) {
1005
1147
  await new Promise((resolve, reject) => {
1006
1148
  let sawSnapshot = false;
@@ -1120,17 +1262,20 @@ function startRuntime(options = {}) {
1120
1262
 
1121
1263
  async function stopRuntime() {
1122
1264
  if (!runtimePid) return;
1265
+ const pid = runtimePid;
1123
1266
  hccMaybe(['down']);
1124
- await waitFor(() => {
1267
+ try {
1268
+ await waitForProcessExit(pid, 'runtime process exit', 5000);
1269
+ } catch (err) {
1270
+ try { process.kill(pid, 'SIGTERM'); } catch {}
1125
1271
  try {
1126
- process.kill(runtimePid, 0);
1127
- return false;
1272
+ await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
1128
1273
  } catch {
1129
- return true;
1274
+ try { process.kill(pid, 'SIGKILL'); } catch {}
1275
+ try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
1276
+ throw err;
1130
1277
  }
1131
- }, 'runtime process exit', 5000).catch(() => {
1132
- try { process.kill(runtimePid, 'SIGTERM'); } catch {}
1133
- });
1278
+ }
1134
1279
  runtimePid = null;
1135
1280
  }
1136
1281
 
@@ -1139,11 +1284,20 @@ function cleanup() {
1139
1284
  if (runtimePid) {
1140
1285
  try { process.kill(runtimePid, 'SIGTERM'); } catch {}
1141
1286
  }
1142
- if (tmuxStarted) {
1143
- runMaybe('tmux', ['kill-session', '-t', tmuxSession]);
1144
- }
1145
- for (const session of managedTmuxSessions) {
1146
- runMaybe('tmux', ['kill-session', '-t', session]);
1287
+ const managedPrefixes = [
1288
+ tmuxManagedSessionPrefix(root),
1289
+ tmuxManagedSessionPrefix(secondProjectRoot)
1290
+ ];
1291
+ for (const endpoint of tmuxCleanupEndpoints()) {
1292
+ for (const session of listTmuxSessionsMatching(managedPrefixes, endpoint)) {
1293
+ managedTmuxSessions.add(session);
1294
+ }
1295
+ if (tmuxStarted) {
1296
+ killTmuxSessionOnEndpoint(endpoint, tmuxSession);
1297
+ }
1298
+ for (const session of managedTmuxSessions) {
1299
+ killTmuxSessionOnEndpoint(endpoint, session);
1300
+ }
1147
1301
  }
1148
1302
  if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
1149
1303
  runMaybe('tmux', ['kill-server']);
@@ -1153,8 +1307,36 @@ function cleanup() {
1153
1307
  }
1154
1308
  }
1155
1309
 
1310
+ function assertNoRealProjectRegistryLeak() {
1311
+ const after = fs.existsSync(realRegistryFile)
1312
+ ? fs.readFileSync(realRegistryFile, 'utf8')
1313
+ : null;
1314
+ if (!after) return;
1315
+
1316
+ let parsed = null;
1317
+ try {
1318
+ parsed = JSON.parse(after);
1319
+ } catch {
1320
+ fail(`regression changed real project registry with invalid JSON: ${realRegistryFile}`);
1321
+ }
1322
+ const leakedRoots = (parsed?.projects || [])
1323
+ .map((project) => String(project?.root || ''))
1324
+ .filter((projectRoot) => {
1325
+ if (!projectRoot) return false;
1326
+ if (projectRoot === root || projectRoot === home || projectRoot === fakeBin || projectRoot === outDir) return true;
1327
+ return projectRoot.startsWith(`${root}${path.sep}`) ||
1328
+ projectRoot.startsWith(`${home}${path.sep}`) ||
1329
+ projectRoot.startsWith(`${fakeBin}${path.sep}`) ||
1330
+ projectRoot.startsWith(`${outDir}${path.sep}`) ||
1331
+ projectRoot.includes(`hcc-reg-${testId}`);
1332
+ });
1333
+ if (leakedRoots.length) {
1334
+ fail(`regression leaked temporary projects into real registry ${realRegistryFile}:\n${leakedRoots.join('\n')}`);
1335
+ }
1336
+ }
1337
+
1156
1338
  async function setupRegression() {
1157
- log('[1/12] web bootstrap/hooks/shims');
1339
+ log('[1/13] web bootstrap/hooks/shims');
1158
1340
  writeFakeTools();
1159
1341
  fs.mkdirSync(root, { recursive: true });
1160
1342
  fs.mkdirSync(home, { recursive: true });
@@ -1202,7 +1384,12 @@ async function setupRegression() {
1202
1384
  fail(`interactive bash did not keep codex shim first: ${interactiveCodex}`);
1203
1385
  }
1204
1386
  if (!hcc(['install-hooks', '--status']).includes('claude=yes codex=yes')) fail('hooks not installed');
1205
- 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
+ }
1206
1393
  const staleShim = path.join(home, '.hcc-shims', 'claude');
1207
1394
  fs.writeFileSync(staleShim, '#!/usr/bin/env bash\necho stale --web-managed\n', { mode: 0o755 });
1208
1395
  const ensured = hccMaybe(['shim', 'ensure', 'claude', staleShim]);
@@ -1213,6 +1400,25 @@ async function setupRegression() {
1213
1400
  if (!refreshedShim.includes('shim ensure "claude"') || refreshedShim.includes('stale --web-managed')) {
1214
1401
  fail(`shim ensure did not refresh stale shim:\n${refreshedShim}`);
1215
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
+ }
1216
1422
  await stopRuntime();
1217
1423
  assertPeerBindingUniqueConstraints();
1218
1424
  assertLegacySchemaMigration();
@@ -1304,16 +1510,18 @@ async function setupRegression() {
1304
1510
  const childDir = path.join(root, 'packages', 'child');
1305
1511
  fs.mkdirSync(childDir, { recursive: true });
1306
1512
  const childFindRoot = hccFrom(['find-root'], childDir).trim();
1307
- if (childFindRoot !== childDir) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1513
+ if (!samePath(childFindRoot, childDir)) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
1308
1514
  const childStatus = hccFrom(['status'], childDir);
1309
- 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'))) {
1310
1517
  fail(`child command did not stay on current path:\n${childStatus}`);
1311
1518
  }
1312
1519
  ensureFile(path.join(childDir, '.hello-cc', 'mesh.db'));
1313
1520
  const explicitChildRoot = hccFrom(['--root', root, 'find-root'], childDir).trim();
1314
- if (explicitChildRoot !== root) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1521
+ if (!samePath(explicitChildRoot, root)) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
1315
1522
  const explicitChildStatus = hccFrom(['--root', root, 'status'], childDir);
1316
- 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'))) {
1317
1525
  fail(`explicit child command did not use requested root:\n${explicitChildStatus}`);
1318
1526
  }
1319
1527
  const childHookPayload = JSON.stringify({ session_id: 'child-hook-session', cwd: childDir, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
@@ -1338,7 +1546,7 @@ async function setupRegression() {
1338
1546
  }
1339
1547
 
1340
1548
  async function dbWorkflow() {
1341
- log('[3/12] db workflow');
1549
+ log('[3/13] db workflow');
1342
1550
  hcc(['register', '--peer', 'human', '--kind', 'human', '--role', 'operator']);
1343
1551
  const created = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'full regression task', '--body', 'exercise hcc bus']);
1344
1552
  const taskMatch = created.match(/created task #(\d+):/);
@@ -1452,6 +1660,38 @@ async function dbWorkflow() {
1452
1660
  hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
1453
1661
  }
1454
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
+
1455
1695
  const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
1456
1696
  const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
1457
1697
  if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
@@ -1505,7 +1745,8 @@ async function dbWorkflow() {
1505
1745
  !staleLivenessState.automation.next_action.argv.includes('stale')) {
1506
1746
  fail(`state automation did not suggest stale takeover:\n${JSON.stringify(staleLivenessState.automation, null, 2)}`);
1507
1747
  }
1508
- hcc(['task', 'update', '--peer', 'human', '--force', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1748
+ hcc(['task', 'takeover', '--peer', 'human', '--id', staleLivenessTaskId, '--reason', 'stale liveness cleanup', '--policy', 'stale', '--stale-after', '60']);
1749
+ hcc(['task', 'update', '--peer', 'human', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1509
1750
  hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
1510
1751
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
1511
1752
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
@@ -1727,13 +1968,14 @@ async function dbWorkflow() {
1727
1968
  }
1728
1969
 
1729
1970
  async function multiProjectWebWorkflow() {
1730
- log('[4/12] multi-project web');
1731
- const otherRoot = path.join(root, 'second-project');
1971
+ log('[4/13] multi-project web');
1972
+ const otherRoot = secondProjectRoot;
1732
1973
  fs.mkdirSync(otherRoot, { recursive: true });
1733
1974
  const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
1734
1975
  if (!output.includes('web already running in background')) fail(`second project did not reuse global web:\n${output}`);
1735
- if (!output.includes(`project: ${otherRoot}`)) fail(`second project output did not show its root:\n${output}`);
1736
- 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}`);
1737
1979
 
1738
1980
  const otherRuntimeFile = path.join(otherRoot, '.hello-cc', 'runtime.json');
1739
1981
  ensureFile(otherRuntimeFile);
@@ -1745,8 +1987,9 @@ async function multiProjectWebWorkflow() {
1745
1987
  const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
1746
1988
  const projects = await projectsResponse.json();
1747
1989
  if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
1748
- const roots = new Set((projects.projects || []).map((p) => p.root));
1749
- 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))) {
1750
1993
  fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
1751
1994
  }
1752
1995
 
@@ -1907,6 +2150,72 @@ async function multiProjectWebWorkflow() {
1907
2150
  fail(`sessions API did not expose unknown provider binding without marking it shared/known:\n${JSON.stringify(otherShellSession, null, 2)}`);
1908
2151
  }
1909
2152
 
2153
+ const startProvider = async (payload) => {
2154
+ const response = await runtimeFetch('/api/sessions', {
2155
+ method: 'POST',
2156
+ headers: { 'Content-Type': 'application/json' },
2157
+ body: JSON.stringify({
2158
+ env: {
2159
+ HOME: home,
2160
+ PATH: env.PATH,
2161
+ SHELL: process.env.SHELL || 'bash',
2162
+ HCC_FAKE_STAY_ALIVE: '1'
2163
+ },
2164
+ ...payload
2165
+ })
2166
+ }, { root });
2167
+ const json = await response.json();
2168
+ if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
2169
+ return json.session;
2170
+ };
2171
+ const stopSession = async (id, payload = null) => {
2172
+ const options = payload
2173
+ ? {
2174
+ method: 'POST',
2175
+ headers: { 'Content-Type': 'application/json' },
2176
+ body: JSON.stringify(payload)
2177
+ }
2178
+ : { method: 'POST' };
2179
+ const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root });
2180
+ const json = await response.json();
2181
+ if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
2182
+ return json.session;
2183
+ };
2184
+
2185
+ const auditSpoofSession = await startProvider({
2186
+ kind: 'shell',
2187
+ command: 'bash --noprofile --norc',
2188
+ auditActorPeer: 'spoofed-web-actor',
2189
+ auditSource: 'spoofed-source'
2190
+ });
2191
+ const auditSpoofPeer = auditSpoofSession.peer_id || auditSpoofSession.id;
2192
+ const attachAudit = eventPayloads('tmux.session.attached', 80)
2193
+ .find((event) => event.payload?.target_peer === auditSpoofPeer);
2194
+ if (!attachAudit ||
2195
+ attachAudit.actor !== 'web' ||
2196
+ attachAudit.payload.actor_peer !== 'web' ||
2197
+ attachAudit.payload.source !== 'web' ||
2198
+ attachAudit.payload.actor_peer === 'spoofed-web-actor' ||
2199
+ attachAudit.payload.source === 'spoofed-source') {
2200
+ fail(`web start audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, attachAudit }, null, 2)}`);
2201
+ }
2202
+ await stopSession(auditSpoofSession.id, {
2203
+ auditActorPeer: 'spoofed-stop-actor',
2204
+ auditSource: 'spoofed-source'
2205
+ });
2206
+ const stopAudit = eventPayloads('web.session.stop_requested', 80)
2207
+ .find((event) => event.payload?.target_peer === auditSpoofPeer);
2208
+ if (!stopAudit ||
2209
+ stopAudit.actor !== 'web' ||
2210
+ stopAudit.payload.actor_peer !== 'web' ||
2211
+ stopAudit.payload.target_peer !== auditSpoofPeer ||
2212
+ stopAudit.payload.source !== 'web' ||
2213
+ stopAudit.payload.admin !== true ||
2214
+ stopAudit.payload.actor_peer === 'spoofed-stop-actor' ||
2215
+ stopAudit.payload.source === 'spoofed-source') {
2216
+ fail(`web stop audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, stopAudit }, null, 2)}`);
2217
+ }
2218
+
1910
2219
  hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1911
2220
  hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
1912
2221
  const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
@@ -1924,10 +2233,28 @@ async function multiProjectWebWorkflow() {
1924
2233
  const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
1925
2234
  const taskMatch = taskOutput.match(/created task #(\d+):/);
1926
2235
  if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
1927
- const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
2236
+ const rejectedActionNextResponse = await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
1928
2237
  method: 'POST',
1929
2238
  headers: { 'Content-Type': 'application/json' },
1930
2239
  body: '{}'
2240
+ }, { root });
2241
+ const rejectedActionNext = await rejectedActionNextResponse.json();
2242
+ if (rejectedActionNextResponse.ok || rejectedActionNext.error?.code !== 'PEER_IDENTITY_REQUIRED') {
2243
+ fail(`web task-next accepted unmanaged peer identity:\n${JSON.stringify(rejectedActionNext, null, 2)}`);
2244
+ }
2245
+ const unmanagedTaskAfterReject = hccJson(['task', 'list', '--all']).find((task) => String(task.id) === taskMatch[1]);
2246
+ if (!unmanagedTaskAfterReject || unmanagedTaskAfterReject.owner) {
2247
+ fail(`rejected web task-next still changed task owner:\n${JSON.stringify(unmanagedTaskAfterReject, null, 2)}`);
2248
+ }
2249
+
2250
+ const managedActionSession = await startProvider({ kind: 'shell', command: 'bash --noprofile --norc' });
2251
+ if (!managedActionSession.action_token) {
2252
+ fail(`managed web session did not include action token:\n${JSON.stringify(managedActionSession, null, 2)}`);
2253
+ }
2254
+ const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
2255
+ method: 'POST',
2256
+ headers: { 'Content-Type': 'application/json' },
2257
+ body: JSON.stringify({ action_token: managedActionSession.action_token })
1931
2258
  }, { root })).json();
1932
2259
  if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
1933
2260
  fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
@@ -1937,31 +2264,51 @@ async function multiProjectWebWorkflow() {
1937
2264
  headers: { 'Content-Type': 'application/json' },
1938
2265
  body: JSON.stringify({ renew_locks: true })
1939
2266
  }, { root })).json();
1940
- if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
1941
- fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
2267
+ if (actionHeartbeat.ok || actionHeartbeat.error?.code !== 'PEER_IDENTITY_REQUIRED') {
2268
+ fail(`web heartbeat accepted unmanaged peer identity:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
1942
2269
  }
1943
2270
 
1944
- const startProvider = async (payload) => {
1945
- const response = await runtimeFetch('/api/sessions', {
1946
- method: 'POST',
1947
- headers: { 'Content-Type': 'application/json' },
1948
- body: JSON.stringify({
1949
- env: {
1950
- HOME: home,
1951
- PATH: env.PATH,
1952
- SHELL: process.env.SHELL || 'bash',
1953
- HCC_FAKE_STAY_ALIVE: '1'
1954
- },
1955
- ...payload
1956
- })
1957
- }, { root });
1958
- const json = await response.json();
1959
- if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
1960
- return json.session;
1961
- };
1962
- const stopSession = async (id) => {
1963
- await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
1964
- };
2271
+ const ownerTask = hcc(['task', 'create', '--from', 'web-lock-owner', '--title', 'web lock owner task']);
2272
+ const ownerTaskMatch = ownerTask.match(/created task #(\d+):/);
2273
+ if (!ownerTaskMatch) fail(`cannot parse web lock owner task id:\n${ownerTask}`);
2274
+ hcc(['task', 'claim', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1]]);
2275
+ const rejectedLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/lock-acquire`, {
2276
+ method: 'POST',
2277
+ headers: { 'Content-Type': 'application/json' },
2278
+ body: JSON.stringify({
2279
+ action_token: managedActionSession.action_token,
2280
+ resource: 'web/identity-spoof-lock',
2281
+ task: Number(ownerTaskMatch[1])
2282
+ })
2283
+ }, { root });
2284
+ const rejectedLock = await rejectedLockResponse.json();
2285
+ if (rejectedLockResponse.ok || rejectedLock.error?.code !== 'TASK_OWNED') {
2286
+ fail(`web lock-acquire did not enforce task owner:\n${JSON.stringify(rejectedLock, null, 2)}`);
2287
+ }
2288
+ const lockRowsAfterReject = hccJson(['lock', 'list', '--all']);
2289
+ if (lockRowsAfterReject.some((lock) => lock.resource === 'web/identity-spoof-lock')) {
2290
+ fail(`rejected web lock-acquire still inserted a lock:\n${JSON.stringify(lockRowsAfterReject, null, 2)}`);
2291
+ }
2292
+ hcc(['task', 'done', '--peer', managedActionSession.peer_id || managedActionSession.id, '--id', taskMatch[1], '--summary', 'web action cleanup'], {
2293
+ env: { ...env, HCC_PEER: managedActionSession.peer_id || managedActionSession.id }
2294
+ });
2295
+ hcc(['task', 'done', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1], '--summary', 'web lock owner cleanup'], {
2296
+ env: { ...env, HCC_PEER: 'web-lock-owner' }
2297
+ });
2298
+ await stopSession(managedActionSession.id);
2299
+
2300
+ hcc(['register', '--peer', 'detected-msg-peer', '--kind', 'codex', '--role', 'peer']);
2301
+ const spoofedDetectedMessage = await (await runtimeFetch('/api/detected/detected-msg-peer/msg', {
2302
+ method: 'POST',
2303
+ headers: { 'Content-Type': 'application/json' },
2304
+ body: JSON.stringify({ from: 'web-action-peer', body: 'detected sender spoof attempt' })
2305
+ }, { root })).json();
2306
+ if (!spoofedDetectedMessage.ok) fail(`detected message send failed:\n${JSON.stringify(spoofedDetectedMessage, null, 2)}`);
2307
+ const detectedInbox = hccJson(['msg', 'inbox', '--peer', 'detected-msg-peer', '--all']);
2308
+ const detectedMsg = detectedInbox.find((message) => message.body === 'detected sender spoof attempt');
2309
+ if (!detectedMsg || detectedMsg.sender !== 'web') {
2310
+ fail(`detected message sender spoof was not forced to web:\n${JSON.stringify(detectedInbox, null, 2)}`);
2311
+ }
1965
2312
 
1966
2313
  const claudeResumeName = `web-claude-resume-${testId}`;
1967
2314
  const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
@@ -2069,11 +2416,11 @@ async function multiProjectWebWorkflow() {
2069
2416
 
2070
2417
  async function tmuxBackedStartWorkflow() {
2071
2418
  if (!tmuxAvailable()) {
2072
- log('[5/12] tmux-backed start skipped (tmux not installed)');
2419
+ log('[5/13] tmux-backed start skipped (tmux not installed)');
2073
2420
  return;
2074
2421
  }
2075
2422
 
2076
- log('[5/12] tmux-backed start + websocket + restore');
2423
+ log('[5/13] tmux-backed start + websocket + restore');
2077
2424
  const file = path.join(outDir, 'pty-ok');
2078
2425
  const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
2079
2426
  const pane = parsePane(started);
@@ -2087,11 +2434,18 @@ async function tmuxBackedStartWorkflow() {
2087
2434
  await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
2088
2435
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
2089
2436
 
2090
- 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
+ }
2091
2444
  run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
2092
2445
  startRuntime();
2093
2446
  await waitRuntime();
2094
- 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
+
2095
2449
  const restoredFile = path.join(outDir, 'pty-restored-ok');
2096
2450
  hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
2097
2451
  await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
@@ -2178,11 +2532,11 @@ async function tmuxBackedStartWorkflow() {
2178
2532
 
2179
2533
  async function shimTmuxWorkflow() {
2180
2534
  if (!tmuxAvailable()) {
2181
- log('[6/12] shim tmux-backed launch skipped (tmux not installed)');
2535
+ log('[6/13] shim tmux-backed launch skipped (tmux not installed)');
2182
2536
  return;
2183
2537
  }
2184
2538
 
2185
- log('[6/12] shim tmux-backed launch');
2539
+ log('[6/13] shim tmux-backed launch');
2186
2540
  const shim = path.join(home, '.hcc-shims', 'claude');
2187
2541
  const codexShim = path.join(home, '.hcc-shims', 'codex');
2188
2542
  const claudeVersion = run(shim, ['--version'], { cwd: root, env });
@@ -2253,6 +2607,9 @@ async function shimTmuxWorkflow() {
2253
2607
  const firstEnvFile = path.join(outDir, 'shim-env-first');
2254
2608
  hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
2255
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');
2256
2613
 
2257
2614
  const restarted = run(shim, ['--resume', 'shim-regression-session'], {
2258
2615
  cwd: root,
@@ -2274,12 +2631,34 @@ async function shimTmuxWorkflow() {
2274
2631
  HCC_FAKE_STAY_ALIVE: '0',
2275
2632
  HCC_FAKE_LOG: exitedLog
2276
2633
  };
2277
- 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 || '';
2278
2650
  const exitedPeerMatch = exitedFirst.match(/started\s+(\S+)\s+\(/);
2279
2651
  if (!exitedPeerMatch) fail(`exited shim did not start a peer:\n${exitedFirst}`);
2280
2652
  const exitedPeer = exitedPeerMatch[1];
2281
- parsePane(exitedFirst);
2282
- 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
+ }
2283
2662
  const fallbackFile = path.join(outDir, 'shim-exited-fallback');
2284
2663
  hcc(['inject', exitedPeer, `printf '%s\\n' fallback > ${sh(fallbackFile)}`]);
2285
2664
  await waitForFile(fallbackFile, 'fallback', 'shim exited fallback shell');
@@ -2292,11 +2671,11 @@ async function shimTmuxWorkflow() {
2292
2671
  async function tmuxWorkflow() {
2293
2672
  const tmuxVersion = runMaybe('tmux', ['-V']);
2294
2673
  if (tmuxVersion.status !== 0) {
2295
- log('[7/12] tmux skipped (tmux not installed)');
2674
+ log('[7/13] tmux skipped (tmux not installed)');
2296
2675
  return;
2297
2676
  }
2298
2677
 
2299
- log('[7/12] tmux attach + websocket + force');
2678
+ log('[7/13] tmux attach + websocket + force');
2300
2679
  run('tmux', ['new-session', '-d', '-s', tmuxSession, '-c', os.tmpdir(), 'bash --noprofile --norc']);
2301
2680
  tmuxStarted = true;
2302
2681
  const pane = run('tmux', ['display-message', '-p', '-t', `${tmuxSession}:0.0`, '#{pane_id}']).trim();
@@ -2318,23 +2697,33 @@ async function tmuxWorkflow() {
2318
2697
 
2319
2698
  async function askBroadcastWorkflow() {
2320
2699
  if (!tmuxAvailable()) {
2321
- log('[8/12] ask/broadcast injection skipped (tmux not installed)');
2700
+ log('[8/13] ask/broadcast injection skipped (tmux not installed)');
2322
2701
  return;
2323
2702
  }
2324
2703
 
2325
- log('[8/12] ask/broadcast injection');
2704
+ log('[8/13] ask/broadcast injection');
2326
2705
  const askFile = path.join(outDir, 'ask-ok');
2327
2706
  parsePane(hcc(['peer', 'start', 'shell-b', '--kind', 'shell', '--', 'bash']));
2328
2707
  hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
2329
2708
  await waitForFile(askFile, 'ASK_OK', 'ask injection');
2330
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');
2331
2720
  const broadcastFile = path.join(outDir, 'broadcast-ok');
2332
2721
  hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
2333
2722
  await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
2334
2723
  }
2335
2724
 
2336
2725
  async function downGcPackWorkflow() {
2337
- log('[9/12] down/gc/pack');
2726
+ log('[9/13] down/gc/pack');
2338
2727
  hccMaybe(['peer', 'stop', 'shell-a']);
2339
2728
  hccMaybe(['peer', 'stop', 'shell-b']);
2340
2729
  await stopRuntime();
@@ -2378,8 +2767,8 @@ async function downGcPackWorkflow() {
2378
2767
  "tags:\n - 'v*'",
2379
2768
  'workflow_dispatch:',
2380
2769
  'contents: write',
2381
- 'actions/checkout@v4',
2382
- 'actions/setup-node@v4',
2770
+ 'actions/checkout@v5',
2771
+ 'actions/setup-node@v5',
2383
2772
  'node-version: 24',
2384
2773
  'npm run release:check',
2385
2774
  'npm run release:github:dry-run',
@@ -2391,7 +2780,7 @@ async function downGcPackWorkflow() {
2391
2780
  }
2392
2781
 
2393
2782
  function oldNameScan() {
2394
- log('[10/12] old-name scan');
2783
+ log('[10/13] old-name scan');
2395
2784
  const oldNamePattern = [
2396
2785
  'agent' + 'mesh',
2397
2786
  'Agent' + 'mesh',
@@ -2402,20 +2791,144 @@ function oldNameScan() {
2402
2791
  'ACTIVE_' + 'AGENT_' + 'TTL',
2403
2792
  '--' + 'agent\\b'
2404
2793
  ].join('|');
2405
- const rg = runMaybe('rg', [
2406
- '-n',
2407
- '--glob', '!node_modules/**',
2408
- '--glob', '!*.tgz',
2409
- '--',
2410
- oldNamePattern,
2411
- '.'
2412
- ]);
2413
- if (rg.status === 0) fail(`old names found:\n${rg.stdout}`);
2414
- 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')}`);
2811
+ }
2812
+
2813
+ function identityEnforcementWorkflow() {
2814
+ log('[11/13] identity enforcement');
2815
+ const identityRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-root-${testId}-`));
2816
+ const identityHome = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-home-${testId}-`));
2817
+ const identityEnv = {
2818
+ ...env,
2819
+ HOME: identityHome,
2820
+ HCC_PEER: 'real-peer'
2821
+ };
2822
+ delete identityEnv.HCC_ROOT;
2823
+ delete identityEnv.HCC_DB;
2824
+ try {
2825
+ hccFrom(['register', '--peer', 'fake-peer', '--kind', 'codex', '--role', 'peer'], identityRoot, { env: identityEnv });
2826
+ const envOut = hccFrom(['env', '--peer', 'fake-peer'], identityRoot, { env: identityEnv });
2827
+ if (!envOut.includes('HCC_PEER=fake-peer')) {
2828
+ fail(`env did not export explicit target peer:\n${envOut}`);
2829
+ }
2830
+ const created = hccFrom(['task', 'create', '--from', 'fake-peer', '--title', 'identity guard'], identityRoot, { env: identityEnv });
2831
+ const match = created.match(/created task #(\d+):/);
2832
+ if (!match) fail(`cannot parse identity task id:\n${created}`);
2833
+ const taskId = match[1];
2834
+ hccFrom(['task', 'claim', '--peer', 'fake-peer', '--id', taskId], identityRoot, { env: identityEnv });
2835
+
2836
+ const dbPath = path.join(identityRoot, '.hello-cc', 'mesh.db');
2837
+ const db = new DatabaseSync(dbPath);
2838
+ try {
2839
+ const task = db.prepare('SELECT owner, created_by FROM tasks WHERE id = ?').get(Number(taskId));
2840
+ const fakePeer = db.prepare('SELECT id FROM peers WHERE id = ?').get('fake-peer');
2841
+ if (task?.owner !== 'real-peer' || task?.created_by !== 'real-peer' || fakePeer) {
2842
+ fail(`system peer identity was not enforced:\n${JSON.stringify({ task, fakePeer }, null, 2)}`);
2843
+ }
2844
+ db.prepare(`
2845
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
2846
+ VALUES ('inspect-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
2847
+ `).run(identityRoot);
2848
+ db.prepare(`
2849
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
2850
+ VALUES ('owner-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
2851
+ `).run(identityRoot);
2852
+ db.prepare(`
2853
+ INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
2854
+ VALUES ('owned identity guard', '', 'claimed', NULL, 'owner-peer', 100, 'owner-peer', 1, 1)
2855
+ `).run();
2856
+ } finally {
2857
+ db.close();
2858
+ }
2859
+
2860
+ const statusOut = hccFrom(['status', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
2861
+ if (!statusOut.includes('inbox(inspect-peer)')) {
2862
+ fail(`status did not inspect explicit target peer:\n${statusOut}`);
2863
+ }
2864
+ const stateOut = hccFrom(['state', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
2865
+ if (!stateOut.includes('peer: inspect-peer')) {
2866
+ fail(`state did not inspect explicit target peer:\n${stateOut}`);
2867
+ }
2868
+
2869
+ const owned = hccFromMaybe(['task', 'done', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
2870
+ if (owned.status === 0 || !`${owned.stdout}\n${owned.stderr}`.includes('Task #2 is owned by owner-peer')) {
2871
+ fail(`owned task mutation was not rejected:\nstdout=${owned.stdout}\nstderr=${owned.stderr}`);
2872
+ }
2873
+ const forcedOwned = hccFromMaybe(['task', 'done', '--force', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
2874
+ if (forcedOwned.status === 0 || !`${forcedOwned.stdout}\n${forcedOwned.stderr}`.includes('Task #2 is owned by owner-peer')) {
2875
+ fail(`forced owned task mutation was not rejected:\nstdout=${forcedOwned.stdout}\nstderr=${forcedOwned.stderr}`);
2876
+ }
2877
+ const db2 = new DatabaseSync(dbPath);
2878
+ try {
2879
+ const task = db2.prepare('SELECT owner, status FROM tasks WHERE id = 2').get();
2880
+ const notice = db2.prepare(`
2881
+ SELECT sender, recipient, kind
2882
+ FROM messages
2883
+ WHERE task_id = 2 AND kind = 'task.owner-conflict'
2884
+ `).get();
2885
+ if (task?.owner !== 'owner-peer' || task?.status !== 'claimed' ||
2886
+ !notice || notice.sender !== 'real-peer' || notice.recipient !== 'owner-peer') {
2887
+ fail(`owned task mutation did not preserve owner and notify:\n${JSON.stringify({ task, notice }, null, 2)}`);
2888
+ }
2889
+ } finally {
2890
+ db2.close();
2891
+ }
2892
+
2893
+ hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
2894
+ const db3 = new DatabaseSync(dbPath);
2895
+ try {
2896
+ const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-peer');
2897
+ const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
2898
+ if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
2899
+ fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
2900
+ }
2901
+ } finally {
2902
+ db3.close();
2903
+ }
2904
+
2905
+ hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
2906
+ const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
2907
+ .find((event) => event.payload?.target_peer === 'fake-target');
2908
+ if (!stopRequested ||
2909
+ stopRequested.actor !== 'real-peer' ||
2910
+ stopRequested.payload.actor_peer !== 'real-peer' ||
2911
+ stopRequested.payload.source !== 'cli' ||
2912
+ stopRequested.payload.admin !== true) {
2913
+ fail(`peer stop request did not record real actor and target:\n${JSON.stringify(stopRequested, null, 2)}`);
2914
+ }
2915
+ const stoppedFallback = eventPayloads('peer.stopped', 5, dbPath)
2916
+ .find((event) => event.payload?.target_peer === 'fake-target');
2917
+ if (!stoppedFallback ||
2918
+ stoppedFallback.actor !== 'real-peer' ||
2919
+ stoppedFallback.payload.actor_peer !== 'real-peer' ||
2920
+ stoppedFallback.payload.source !== 'cli' ||
2921
+ stoppedFallback.payload.admin !== true) {
2922
+ fail(`peer stop fallback did not record real actor and target:\n${JSON.stringify(stoppedFallback, null, 2)}`);
2923
+ }
2924
+ } finally {
2925
+ try { fs.rmSync(identityRoot, { recursive: true, force: true }); } catch {}
2926
+ try { fs.rmSync(identityHome, { recursive: true, force: true }); } catch {}
2927
+ }
2415
2928
  }
2416
2929
 
2417
2930
  async function syntaxAndHelp() {
2418
- log('[11/12] syntax/help');
2931
+ log('[12/13] syntax/help');
2419
2932
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
2420
2933
  for (const file of libModuleFiles()) {
2421
2934
  run(process.execPath, ['--check', path.join(repoRoot, file)]);
@@ -2601,7 +3114,19 @@ async function syntaxAndHelp() {
2601
3114
  '} = createWebPeerActions({',
2602
3115
  'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2603
3116
  "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2604
- "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
3117
+ 'resolveWebActionSession(reqCtx, peer, input, req)',
3118
+ 'action_token: session.actionToken || null',
3119
+ "const sender = 'web';",
3120
+ 'function auditPayload({ actor = null, target = null, source = ',
3121
+ 'payload.actor_peer = actor',
3122
+ 'payload.target_peer = target',
3123
+ "auditActorPeer: 'web'",
3124
+ "auditSource: 'web'",
3125
+ "addEvent(db, 'peer.start.requested'",
3126
+ "addEvent(db, 'peer.attach.requested'",
3127
+ "addEvent(db, 'peer.stop.requested'",
3128
+ "addEvent(eventDb, 'web.session.stop_requested'",
3129
+ "addEvent(db, 'tmux.session.gc'"
2605
3130
  ]) {
2606
3131
  if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2607
3132
  }
@@ -2618,9 +3143,17 @@ async function syntaxAndHelp() {
2618
3143
  if (hccSource.includes(helper)) fail(`CLI still embeds web peer action helper: ${helper}`);
2619
3144
  }
2620
3145
  for (const expected of [
3146
+ 'function webMutationPeer(peer, input = {})',
2621
3147
  'function webPeerAction(projectCtx, peer, action, input = {})',
3148
+ 'function webAuditPayload(peer, input = {}, extra = {})',
3149
+ "source: 'web'",
3150
+ 'actor_peer: actorPeer',
3151
+ 'target_peer: peer',
3152
+ "throw new CliError('PEER_IDENTITY_MISMATCH'",
3153
+ "throw new CliError('PEER_IDENTITY_REQUIRED'",
2622
3154
  'claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count })',
2623
3155
  'takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: ',
3156
+ "assertTaskOwnerForMutation(db, peer, task, 'lock-acquire')",
2624
3157
  'const status = statusSummary(projectCtx, peer)',
2625
3158
  "normalized === 'task-next'",
2626
3159
  "normalized === 'lock-acquire'",
@@ -2777,6 +3310,7 @@ async function syntaxAndHelp() {
2777
3310
  'function installShims',
2778
3311
  'function uninstallShims',
2779
3312
  'function verifyShims',
3313
+ 'function shimStatus',
2780
3314
  'function findRealBinary',
2781
3315
  'function fsExists'
2782
3316
  ]) {
@@ -2804,7 +3338,8 @@ async function syntaxAndHelp() {
2804
3338
  }
2805
3339
  if (!integrationShimScriptSource.includes('function generateShim') ||
2806
3340
  !integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
2807
- !integrationShimScriptSource.includes('HCC_SHIM_ENSURED')) {
3341
+ !integrationShimScriptSource.includes('HCC_SHIM_ENSURED') ||
3342
+ !integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL')) {
2808
3343
  fail('integrations shim script module is missing expected generateShim wiring');
2809
3344
  }
2810
3345
  if (!shellPathSource.includes('function installPathEntry') ||
@@ -2829,7 +3364,7 @@ async function syntaxAndHelp() {
2829
3364
  if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
2830
3365
  if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
2831
3366
  }
2832
- for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
3367
+ for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'shimStatus', 'findRealBinary']) {
2833
3368
  if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
2834
3369
  if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
2835
3370
  }
@@ -2843,6 +3378,147 @@ async function syntaxAndHelp() {
2843
3378
  if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
2844
3379
  if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
2845
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
+ }
2846
3522
  const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
2847
3523
  for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
2848
3524
  if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
@@ -3232,11 +3908,13 @@ async function syntaxAndHelp() {
3232
3908
  ],
3233
3909
  events: [
3234
3910
  { id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
3235
- { id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 }
3911
+ { id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 },
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 }
3236
3914
  ]
3237
3915
  }, 'peer-b');
3238
3916
  const timelineIds = timelineItems.map((item) => item.id);
3239
- if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8'])) {
3917
+ if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9', 'event:10'])) {
3240
3918
  fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
3241
3919
  }
3242
3920
  const inbound = timelineItems.find((item) => item.id === 'message:2');
@@ -4545,6 +5223,10 @@ async function syntaxAndHelp() {
4545
5223
  if (integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell').id !== 'env-peer') {
4546
5224
  fail('peer identity resolveCurrentPeer ignored HCC_PEER');
4547
5225
  }
5226
+ const correctedPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, { peer: 'manual-peer' }, 'peer', 'shell');
5227
+ if (correctedPeer.id !== 'env-peer' || correctedPeer.corrected_from !== 'manual-peer') {
5228
+ fail(`peer identity did not correct explicit peer to HCC_PEER: ${JSON.stringify(correctedPeer)}`);
5229
+ }
4548
5230
  delete process.env.HCC_PEER;
4549
5231
  delete process.env.CLAUDE_CODE_SESSION_ID;
4550
5232
  delete process.env.CLAUDECODE;
@@ -4783,12 +5465,25 @@ async function syntaxAndHelp() {
4783
5465
  if (!taskHelp.includes('task next [--peer ID] [--force]') ||
4784
5466
  !taskHelp.includes('[--count N]') ||
4785
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') ||
4786
5472
  !taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
4787
5473
  !taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
4788
5474
  !taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
4789
5475
  !taskHelp.includes('task create --title TEXT --parent N')) {
4790
5476
  fail(`task help missing current-task task next semantics:\n${taskHelp}`);
4791
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
+ }
4792
5487
  const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
4793
5488
  if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
4794
5489
  fail(`team help missing expected content:\n${teamHelp}`);
@@ -4817,6 +5512,19 @@ async function syntaxAndHelp() {
4817
5512
  !tmuxHelp.includes('deletion requires --yes')) {
4818
5513
  fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
4819
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
+ }
4820
5528
  const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
4821
5529
  if (!gcHelp.includes('hcc gc') ||
4822
5530
  !gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
@@ -4864,7 +5572,7 @@ async function syntaxAndHelp() {
4864
5572
  }
4865
5573
 
4866
5574
  function uninstallWorkflow() {
4867
- log('[12/12] maintenance cleanup');
5575
+ log('[13/13] maintenance cleanup');
4868
5576
  const uninstallRoot = path.join(os.tmpdir(), `hcc-reg-uninstall-root-${testId}`);
4869
5577
  const uninstallHome = path.join(os.tmpdir(), `hcc-reg-uninstall-home-${testId}`);
4870
5578
  fs.mkdirSync(uninstallRoot, { recursive: true });
@@ -4886,11 +5594,16 @@ function uninstallWorkflow() {
4886
5594
  ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
4887
5595
  ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
4888
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
+ }
4889
5601
 
4890
5602
  const kept = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall'], { env: uninstallEnv });
4891
5603
  if (!kept.includes('project data kept')) fail(`uninstall did not keep project data by default:\n${kept}`);
4892
5604
  if (!fs.existsSync(path.join(uninstallRoot, '.hello-cc', 'mesh.db'))) fail('default uninstall removed project db');
4893
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');
4894
5607
  if (!run(process.execPath, [hccBin, '--root', uninstallRoot, 'install-hooks', '--status'], { env: uninstallEnv }).includes('claude=no codex=no')) {
4895
5608
  fail('default uninstall did not remove hooks');
4896
5609
  }
@@ -4918,7 +5631,7 @@ async function main() {
4918
5631
  process.once('SIGTERM', () => { cleanup(); process.exit(143); });
4919
5632
 
4920
5633
  await setupRegression();
4921
- log('[2/12] runtime');
5634
+ log('[2/13] runtime');
4922
5635
  startRuntime();
4923
5636
  await waitRuntime();
4924
5637
  hcc(['peer', 'list']);
@@ -4930,15 +5643,26 @@ async function main() {
4930
5643
  await askBroadcastWorkflow();
4931
5644
  await downGcPackWorkflow();
4932
5645
  oldNameScan();
5646
+ identityEnforcementWorkflow();
4933
5647
  await syntaxAndHelp();
4934
5648
  uninstallWorkflow();
5649
+ assertNoRealProjectRegistryLeak();
5650
+ cleanup();
5651
+ assertNoRegressionTmuxSessionLeak();
4935
5652
  log('FULL_REGRESSION_OK');
4936
5653
  }
4937
5654
 
4938
5655
  main().catch((err) => {
5656
+ try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
5657
+ process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
5658
+ }
4939
5659
  cleanup();
4940
5660
  process.stderr.write(`${err.stack || err.message}\n`);
4941
5661
  process.exit(1);
4942
5662
  }).finally(() => {
4943
- cleanup();
5663
+ try {
5664
+ assertNoRealProjectRegistryLeak();
5665
+ } finally {
5666
+ cleanup();
5667
+ }
4944
5668
  });