@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.
package/bin/hcc.mjs CHANGED
@@ -227,6 +227,8 @@ const {
227
227
  helpDown,
228
228
  helpUpdate,
229
229
  helpUninstall,
230
+ helpInstallHooks,
231
+ helpShim,
230
232
  helpWeb
231
233
  } = createHelpFunctions({
232
234
  productName: PRODUCT_NAME,
@@ -279,7 +281,11 @@ function splitProcessArgs(line) {
279
281
 
280
282
  function sameResolvedPath(a, b) {
281
283
  if (!a || !b) return false;
282
- return path.resolve(a) === path.resolve(b);
284
+ function key(value) {
285
+ try { return fs.realpathSync(value); }
286
+ catch { return path.resolve(value); }
287
+ }
288
+ return key(a) === key(b);
283
289
  }
284
290
 
285
291
  const {
@@ -291,6 +297,7 @@ const {
291
297
  sendMessage
292
298
  } = createMessageStore({ now, addEvent });
293
299
  const {
300
+ assertTaskOwnerForMutation,
294
301
  claimNextTasksForPeer,
295
302
  claimTaskRowsForPeer,
296
303
  queryOpenTasks,
@@ -325,6 +332,7 @@ const {
325
332
  } = createWebPeerActions({
326
333
  activePeerTtl: ACTIVE_PEER_TTL,
327
334
  addEvent,
335
+ assertTaskOwnerForMutation,
328
336
  claimNextTasksForPeer,
329
337
  connect,
330
338
  defaultLockTtl: DEFAULT_LOCK_TTL,
@@ -351,6 +359,15 @@ function iso(ts) {
351
359
  return new Date(ts * 1000).toISOString();
352
360
  }
353
361
 
362
+ function webErrorStatus(err) {
363
+ if (!(err instanceof CliError)) return 500;
364
+ if (['BAD_ARGS', 'BAD_REQUEST', 'PEER_IDENTITY_REQUIRED', 'REQUEST_TOO_LARGE'].includes(err.code)) return 400;
365
+ if (['PEER_IDENTITY_MISMATCH', 'TASK_OWNED', 'LOCK_OWNED'].includes(err.code)) return 403;
366
+ if (['NOT_FOUND'].includes(err.code)) return 404;
367
+ if (['LOCK_HELD', 'SESSION_NOT_RUNNING'].includes(err.code)) return 409;
368
+ return 500;
369
+ }
370
+
354
371
  function autoPeerDefaults(ctx, kindHint = 'shell', status = 'working') {
355
372
  const kind = autoPeerKind(kindHint);
356
373
  const ancestor = readAncestorCliInfo();
@@ -369,6 +386,11 @@ function shellCommand(args) {
369
386
  return shellCommandWithQuote(args, shellQuoteArg);
370
387
  }
371
388
 
389
+ function resolveTargetPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell') {
390
+ if (opts[key]) return { id: opts[key], auto: false, target: true };
391
+ return resolveCurrentPeer(ctx, opts, key, kindHint);
392
+ }
393
+
372
394
  let projectMigrationFanoutDepth = 0;
373
395
  const migratedRegisteredProjectDbs = new Set();
374
396
 
@@ -426,6 +448,22 @@ function addEvent(db, type, actor, taskId, payload) {
426
448
  `).run(type, actor || null, taskId || null, JSON.stringify(payload || {}), now());
427
449
  }
428
450
 
451
+ function auditPayload({ actor = null, target = null, source = 'cli', admin = false, ...extra } = {}) {
452
+ const payload = { ...extra, source };
453
+ if (actor) payload.actor_peer = actor;
454
+ if (target) payload.target_peer = target;
455
+ if (admin) payload.admin = true;
456
+ return payload;
457
+ }
458
+
459
+ function requestActorPeer(input = {}, fallback = 'web') {
460
+ return String(input.auditActorPeer || fallback || 'web').trim() || 'web';
461
+ }
462
+
463
+ function requestSource(input = {}, fallback = 'web') {
464
+ return String(input.auditSource || fallback || 'web').trim() || fallback;
465
+ }
466
+
429
467
  function latestHookProviderSession(db, peer) {
430
468
  if (!peer) return null;
431
469
  try {
@@ -583,7 +621,8 @@ async function cmdInit(ctx, args) {
583
621
  async function cmdRegister(ctx, args) {
584
622
  registerProjectActivity(ctx);
585
623
  const opts = parseOpts(args, { arrays: ['cap'] });
586
- const id = required(opts, 'peer', 'HCC_PEER');
624
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', opts.kind || 'shell');
625
+ const id = identity.id;
587
626
  const db = connect(ctx);
588
627
  const peer = {
589
628
  id,
@@ -603,7 +642,7 @@ async function cmdRegister(ctx, args) {
603
642
  async function cmdEnv(ctx, args) {
604
643
  if (args[0] === '--help' || args[0] === '-h') return helpEnv();
605
644
  const opts = parseOpts(args);
606
- const peer = required(opts, 'peer', 'HCC_PEER');
645
+ const peer = resolveTargetPeer(ctx, opts, 'peer', 'shell').id;
607
646
  const values = {
608
647
  HCC_PEER: peer,
609
648
  HCC_ROOT: ctx.root,
@@ -616,7 +655,8 @@ async function cmdJoin(ctx, args) {
616
655
  if (args[0] === '--help' || args[0] === '-h') return helpJoin();
617
656
  registerProjectActivity(ctx);
618
657
  const opts = parseOpts(args, { arrays: ['cap'] });
619
- const id = required(opts, 'peer', 'HCC_PEER');
658
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', opts.kind || 'shell');
659
+ const id = identity.id;
620
660
  const peer = {
621
661
  id,
622
662
  kind: opts.kind || 'other',
@@ -697,6 +737,7 @@ async function cmdTask(ctx, args) {
697
737
  const sub = args[0];
698
738
  if (!sub || wantsHelp(args)) return helpTask();
699
739
  if (sub === 'create') return taskCreate(ctx, args.slice(1));
740
+ if (sub === 'dispatch') return taskDispatch(ctx, args.slice(1));
700
741
  if (sub === 'list') return taskList(ctx, args.slice(1));
701
742
  if (sub === 'claim') return taskClaim(ctx, args.slice(1));
702
743
  if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
@@ -738,6 +779,201 @@ async function taskCreate(ctx, args) {
738
779
  (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
739
780
  }
740
781
 
782
+ function dispatchPromptText(task, customMessage = null) {
783
+ if (customMessage) return customMessage;
784
+ return [
785
+ `Please pick up hello-cc task #${task.id}: ${task.title}.`,
786
+ `Run hcc task claim --id ${task.id}, then follow project coordination rules, create a handoff, and mark the task done when finished.`
787
+ ].join(' ');
788
+ }
789
+
790
+ function currentOwnedTaskForPeer(db, peer) {
791
+ return db.prepare(`
792
+ SELECT *
793
+ FROM tasks
794
+ WHERE owner = ?
795
+ AND status IN ('claimed', 'running', 'review', 'blocked')
796
+ ORDER BY
797
+ CASE status
798
+ WHEN 'running' THEN 0
799
+ WHEN 'claimed' THEN 1
800
+ WHEN 'review' THEN 2
801
+ WHEN 'blocked' THEN 3
802
+ ELSE 4
803
+ END,
804
+ priority ASC,
805
+ id ASC
806
+ LIMIT 1
807
+ `).get(peer);
808
+ }
809
+
810
+ function findRuntimeSessionForPeer(runtimeData, peer) {
811
+ return (runtimeData?.sessions || []).find((session) => {
812
+ const sessionPeer = session.peer_id || session.id;
813
+ return session.status === 'running' && (session.id === peer || sessionPeer === peer);
814
+ }) || null;
815
+ }
816
+
817
+ function sessionLooksProviderInteractive(session) {
818
+ if (!['claude', 'codex'].includes(session?.kind)) return false;
819
+ if (session.provider_session_known) return true;
820
+ const command = String(session.command || '');
821
+ if (/^tmux\s+%/.test(command)) return false;
822
+ return /\b(?:claude|codex)(?:\s|$)/.test(command);
823
+ }
824
+
825
+ async function taskDispatch(ctx, args) {
826
+ const opts = parseOpts(args, { booleans: ['force', 'no-inject'] });
827
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
828
+ const actor = identity.id;
829
+ const target = required(opts, 'to');
830
+ const requestedTaskId = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
831
+ const title = requestedTaskId ? (opts.title || null) : required(opts, 'title');
832
+ const body = opts.body || '';
833
+ const priority = intOpt(opts, 'priority', 100);
834
+ const customMessage = opts.message ? String(opts.message) : null;
835
+ const injectAllowed = !Boolean(opts['no-inject']);
836
+
837
+ let task = null;
838
+ let messageId = null;
839
+ let currentTask = null;
840
+ let previousAssignee = null;
841
+ const db = connect(ctx);
842
+ try {
843
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
844
+ const t = now();
845
+ task = tx(db, () => {
846
+ if (requestedTaskId) {
847
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
848
+ if (!existing) throw new CliError('NOT_FOUND', `Task #${requestedTaskId} does not exist`);
849
+ if (['done', 'abandoned'].includes(existing.status)) {
850
+ throw new CliError('BAD_STATE', `Task #${requestedTaskId} is ${existing.status}`);
851
+ }
852
+ if (existing.owner && existing.owner !== target) {
853
+ throw new CliError('TASK_OWNED', `Task #${requestedTaskId} is owned by ${existing.owner}`, {
854
+ owner: existing.owner,
855
+ task_id: requestedTaskId,
856
+ attempted_by: actor,
857
+ target
858
+ });
859
+ }
860
+ previousAssignee = existing.assignee || null;
861
+ db.prepare('UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?').run(target, t, requestedTaskId);
862
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
863
+ }
864
+ const info = db.prepare(`
865
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
866
+ VALUES (?, ?, 'pending', ?, NULL, NULL, NULL, ?, ?, ?, ?)
867
+ `).run(title, body, target, priority, actor, t, t);
868
+ const taskId = Number(info.lastInsertRowid);
869
+ addEvent(db, 'task.created', actor, taskId, { title, assignee: target, priority, parent_id: null, team_role: null });
870
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
871
+ });
872
+ const durableMessage = dispatchPromptText(task, customMessage);
873
+ messageId = sendMessage(db, actor, target, task.id, 'task', durableMessage);
874
+ currentTask = currentOwnedTaskForPeer(db, target);
875
+ } finally {
876
+ db.close();
877
+ }
878
+
879
+ const durableMessage = dispatchPromptText(task, customMessage);
880
+ let session = null;
881
+ let injected = false;
882
+ let injectionReason = injectAllowed ? 'runtime_unavailable' : 'no_inject';
883
+ const busyTask = currentTask && Number(currentTask.id) !== Number(task.id) ? currentTask : null;
884
+ if (injectAllowed) {
885
+ let runtime = null;
886
+ let runtimeData = null;
887
+ try {
888
+ runtime = readRuntime(ctx);
889
+ runtimeData = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
890
+ } catch (err) {
891
+ if (!(err instanceof CliError) || !['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) throw err;
892
+ }
893
+ session = runtimeData ? findRuntimeSessionForPeer(runtimeData, target) : null;
894
+ if (!session) {
895
+ injectionReason = runtimeData ? 'session_not_running' : 'runtime_unavailable';
896
+ } else if (!customMessage && !sessionLooksProviderInteractive(session)) {
897
+ injectionReason = 'unsupported_session_kind';
898
+ } else if (busyTask && !Boolean(opts.force)) {
899
+ injectionReason = 'target_busy';
900
+ } else {
901
+ try {
902
+ await injectPeer(ctx, target, durableMessage, true, runtime, actor);
903
+ injected = true;
904
+ injectionReason = 'injected';
905
+ } catch (err) {
906
+ if (!(err instanceof CliError)) throw err;
907
+ if (['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
908
+ injectionReason = 'runtime_unavailable';
909
+ } else if (['NOT_FOUND', 'SESSION_NOT_RUNNING'].includes(err.code)) {
910
+ injectionReason = 'session_not_running';
911
+ } else {
912
+ throw err;
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ const eventDb = connect(ctx);
919
+ try {
920
+ addEvent(eventDb, 'task.dispatched', actor, task.id, auditPayload({
921
+ actor,
922
+ target,
923
+ source: 'cli',
924
+ admin: actor !== target,
925
+ peer: target,
926
+ title: task.title,
927
+ message_id: messageId,
928
+ injected,
929
+ delivery: injected ? 'message+inject' : 'message-only',
930
+ injection_reason: injectionReason,
931
+ session_id: session?.id || null,
932
+ session_kind: session?.kind || null,
933
+ previous_assignee: previousAssignee,
934
+ blocked_by_task_id: busyTask?.id || null
935
+ }));
936
+ } finally {
937
+ eventDb.close();
938
+ }
939
+
940
+ const result = {
941
+ task,
942
+ target,
943
+ message_id: messageId,
944
+ message: durableMessage,
945
+ injected,
946
+ delivery: injected ? 'message+inject' : 'message-only',
947
+ injection_reason: injectionReason,
948
+ session: session ? {
949
+ id: session.id,
950
+ peer_id: session.peer_id || session.id,
951
+ kind: session.kind,
952
+ status: session.status
953
+ } : null,
954
+ previous_assignee: previousAssignee,
955
+ blocked_by_task: busyTask ? {
956
+ id: busyTask.id,
957
+ status: busyTask.status,
958
+ title: busyTask.title
959
+ } : null
960
+ };
961
+ printResult(ctx, result, (data) => {
962
+ const base = `dispatched task #${data.task.id} to ${data.target} with message #${data.message_id}`;
963
+ if (data.injected) return `${base} and injected live input`;
964
+ if (data.injection_reason === 'target_busy' && data.blocked_by_task) {
965
+ return `${base} (not injected: ${data.target} already owns task #${data.blocked_by_task.id})`;
966
+ }
967
+ if (data.injection_reason === 'unsupported_session_kind' && data.session) {
968
+ return `${base} (not injected: managed ${data.session.kind} session needs an explicit shell-safe message)`;
969
+ }
970
+ if (data.injection_reason === 'session_not_running') return `${base} (not injected: target is not a running managed session)`;
971
+ if (data.injection_reason === 'runtime_unavailable') return `${base} (not injected: web runtime is unavailable)`;
972
+ if (data.injection_reason === 'no_inject') return `${base} (message only)`;
973
+ return `${base} (not injected: ${data.injection_reason})`;
974
+ });
975
+ }
976
+
741
977
  async function taskList(ctx, args) {
742
978
  const opts = parseOpts(args, { booleans: ['all'] });
743
979
  const status = opts.status || null;
@@ -794,10 +1030,39 @@ async function taskClaim(ctx, args) {
794
1030
  const ids = parseTaskIds(opts);
795
1031
  const db = connect(ctx);
796
1032
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
797
- const tasks = claimTaskRowsForPeer(db, peer, ids, { force: Boolean(opts.force) });
1033
+ let tasks;
1034
+ try {
1035
+ tasks = claimTaskRowsForPeer(db, peer, ids, { force: Boolean(opts.force) });
1036
+ } catch (err) {
1037
+ notifyTaskOwnerConflict(ctx, err);
1038
+ throw err;
1039
+ }
798
1040
  printResult(ctx, ids.length === 1 ? tasks[0] : tasks, (data) => taskRowsText(Array.isArray(data) ? data : [data], 'claimed'));
799
1041
  }
800
1042
 
1043
+ function notifyTaskOwnerConflict(ctx, err) {
1044
+ if (err?.code !== 'TASK_OWNED' || !err.extra?.notify_owner) return;
1045
+ const { owner, task_id: taskId, attempted_by: attemptedBy, action } = err.extra;
1046
+ if (!owner || !attemptedBy || owner === attemptedBy) return;
1047
+ let db = null;
1048
+ try {
1049
+ db = connect(ctx);
1050
+ sendMessage(
1051
+ db,
1052
+ attemptedBy,
1053
+ owner,
1054
+ taskId || null,
1055
+ 'task.owner-conflict',
1056
+ `Task #${taskId} is owned by ${owner}; ${attemptedBy} attempted ${action || 'modify'} and hello-cc left ownership unchanged.`
1057
+ );
1058
+ err.extra.notified = true;
1059
+ } catch {
1060
+ err.extra.notified = false;
1061
+ } finally {
1062
+ try { db?.close(); } catch {}
1063
+ }
1064
+ }
1065
+
801
1066
  async function taskTakeover(ctx, args) {
802
1067
  const opts = parseOpts(args);
803
1068
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
@@ -842,25 +1107,29 @@ async function taskUpdate(ctx, args) {
842
1107
  if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
843
1108
  const db = connect(ctx);
844
1109
  touchCurrentPeer(db, ctx, identity, status === 'done' ? 'idle' : 'working', 'shell');
845
- const task = tx(db, () => {
846
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
847
- if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
848
- if (row.owner && row.owner !== peer && !opts.force) {
849
- throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
850
- }
851
- const t = now();
852
- const completedAt = status === 'done' ? t : row.completed_at;
853
- db.prepare(`
854
- UPDATE tasks
855
- SET status = ?, completed_at = ?, updated_at = ?
856
- WHERE id = ?
857
- `).run(status, completedAt, t, id);
858
- addEvent(db, `task.${status}`, peer, id, { summary: opts.summary || opts.reason || '' });
859
- if (opts.body) {
860
- sendMessage(db, peer, opts.to || 'all', id, 'task.update', opts.body);
861
- }
862
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
863
- });
1110
+ let task;
1111
+ try {
1112
+ task = tx(db, () => {
1113
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
1114
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
1115
+ assertTaskOwnerForMutation(db, peer, row, `update:${status}`);
1116
+ const t = now();
1117
+ const completedAt = status === 'done' ? t : row.completed_at;
1118
+ db.prepare(`
1119
+ UPDATE tasks
1120
+ SET status = ?, completed_at = ?, updated_at = ?
1121
+ WHERE id = ?
1122
+ `).run(status, completedAt, t, id);
1123
+ addEvent(db, `task.${status}`, peer, id, { summary: opts.summary || opts.reason || '' });
1124
+ if (opts.body) {
1125
+ sendMessage(db, peer, opts.to || 'all', id, 'task.update', opts.body);
1126
+ }
1127
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
1128
+ });
1129
+ } catch (err) {
1130
+ notifyTaskOwnerConflict(ctx, err);
1131
+ throw err;
1132
+ }
864
1133
  printResult(ctx, task, (data) => `task #${data.id} -> ${data.status}`);
865
1134
  }
866
1135
 
@@ -1128,7 +1397,7 @@ async function cmdAsk(ctx, args) {
1128
1397
  const id = sendMessage(db, sender, recipient, taskId, kind, body);
1129
1398
  let injected = false;
1130
1399
  if (opts.inject) {
1131
- await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime);
1400
+ await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime, sender);
1132
1401
  injected = true;
1133
1402
  }
1134
1403
  printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, injected }, (data) => `asked ${data.recipient} with message #${data.id}${data.injected ? ' and injected terminal input' : ''}`);
@@ -1152,15 +1421,33 @@ async function cmdBroadcast(ctx, args) {
1152
1421
  const sessions = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
1153
1422
  const running = (sessions.sessions || []).filter((session) => session.status === 'running');
1154
1423
  for (const session of running) {
1155
- await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime);
1424
+ await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
1156
1425
  injected += 1;
1157
1426
  }
1158
1427
  }
1159
1428
  printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected }, (data) => `broadcast message #${data.id}${data.injected ? ` and injected ${data.injected} terminal(s)` : ''}`);
1160
1429
  }
1161
1430
 
1162
- async function injectPeer(ctx, peer, text, enter = true, runtime = null) {
1163
- return runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(peer)}/input`, { text, enter }, runtime);
1431
+ async function injectPeer(ctx, peer, text, enter = true, runtime = null, auditActor = null) {
1432
+ const actor = auditActor || resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1433
+ const db = connect(ctx);
1434
+ try {
1435
+ addEvent(db, 'web.session.input.requested', actor, null, auditPayload({
1436
+ actor,
1437
+ target: peer,
1438
+ peer,
1439
+ source: 'cli',
1440
+ admin: actor !== peer,
1441
+ bytes: text.length,
1442
+ enter
1443
+ }));
1444
+ } finally {
1445
+ db.close();
1446
+ }
1447
+ return runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(peer)}/input`, {
1448
+ text,
1449
+ enter
1450
+ }, runtime);
1164
1451
  }
1165
1452
 
1166
1453
  async function cmdInject(ctx, args) {
@@ -1227,6 +1514,7 @@ async function peerStart(ctx, args) {
1227
1514
  const optArgs = sep >= 0 ? args.slice(0, sep) : args;
1228
1515
  const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
1229
1516
  const opts = parseOpts(optArgs, { booleans: ['last', 'continue', 'fork', 'force', 'restart-env'] });
1517
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1230
1518
  const id = opts.peer || opts._[0];
1231
1519
  if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
1232
1520
  const firstCommand = cmdArgs[0] || '';
@@ -1269,6 +1557,17 @@ async function peerStart(ctx, args) {
1269
1557
  status: 'starting',
1270
1558
  capabilities: 'tmux'
1271
1559
  });
1560
+ addEvent(db, 'peer.start.requested', actor, null, auditPayload({
1561
+ actor,
1562
+ target: id,
1563
+ peer: id,
1564
+ source: 'cli',
1565
+ admin: actor !== id,
1566
+ kind,
1567
+ role,
1568
+ command,
1569
+ cwd
1570
+ }));
1272
1571
  });
1273
1572
  } finally {
1274
1573
  db.close();
@@ -1290,6 +1589,7 @@ async function peerStart(ctx, args) {
1290
1589
 
1291
1590
  async function peerAttach(ctx, args) {
1292
1591
  const opts = parseOpts(args, { booleans: ['force'] });
1592
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1293
1593
  const id = opts.peer || opts._[0];
1294
1594
  if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
1295
1595
  const pane = opts.pane || process.env.TMUX_PANE || null;
@@ -1306,6 +1606,22 @@ async function peerAttach(ctx, args) {
1306
1606
  throw e;
1307
1607
  }
1308
1608
  await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime);
1609
+ const db = connect(ctx);
1610
+ try {
1611
+ addEvent(db, 'peer.attach.requested', actor, null, auditPayload({
1612
+ actor,
1613
+ target: id,
1614
+ peer: id,
1615
+ source: 'cli',
1616
+ admin: actor !== id,
1617
+ pane,
1618
+ kind,
1619
+ role,
1620
+ cwd
1621
+ }));
1622
+ } finally {
1623
+ db.close();
1624
+ }
1309
1625
  const data = await runtimeRequest(ctx, 'POST', '/api/sessions/attach', {
1310
1626
  id,
1311
1627
  kind,
@@ -1319,10 +1635,23 @@ async function peerAttach(ctx, args) {
1319
1635
 
1320
1636
  async function peerStop(ctx, args) {
1321
1637
  const opts = parseOpts(args);
1638
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
1322
1639
  const id = opts.peer || opts._[0];
1323
1640
  if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
1324
1641
  let data;
1325
1642
  try {
1643
+ const db = connect(ctx);
1644
+ try {
1645
+ addEvent(db, 'peer.stop.requested', actor, null, auditPayload({
1646
+ actor,
1647
+ target: id,
1648
+ peer: id,
1649
+ source: 'cli',
1650
+ admin: actor !== id
1651
+ }));
1652
+ } finally {
1653
+ db.close();
1654
+ }
1326
1655
  data = await runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(id)}/stop`, {});
1327
1656
  } catch (err) {
1328
1657
  if (err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') {
@@ -1330,7 +1659,12 @@ async function peerStop(ctx, args) {
1330
1659
  const db = connect(ctx);
1331
1660
  try {
1332
1661
  db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
1333
- addEvent(db, 'peer.stopped', process.env.HCC_PEER || 'human', null, { peer: id });
1662
+ addEvent(db, 'peer.stopped', actor, null, auditPayload({
1663
+ actor,
1664
+ target: id,
1665
+ peer: id,
1666
+ admin: actor !== id
1667
+ }));
1334
1668
  } finally { db.close(); }
1335
1669
  printResult(ctx, { id, status: 'exited' }, (s) => `stopped ${s.id} (no server running — DB marked exited)`);
1336
1670
  return;
@@ -1360,35 +1694,46 @@ async function lockAcquire(ctx, args) {
1360
1694
  const reason = opts.reason || '';
1361
1695
  const db = connect(ctx);
1362
1696
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
1363
- const lock = tx(db, () => {
1364
- const t = now();
1365
- const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
1366
- const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
1367
- if (conflict) {
1368
- throw new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
1369
- resource: requested.base_resource,
1370
- scope: requested.scope,
1371
- lock_resource: conflict.resource,
1372
- lock_scope: lockScope(conflict),
1373
- owner: conflict.owner,
1374
- expires_at: iso(conflict.expires_at)
1375
- });
1376
- }
1377
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
1378
- db.prepare(`
1379
- INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
1380
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1381
- ON CONFLICT(resource) DO UPDATE SET
1382
- base_resource = excluded.base_resource,
1383
- scope = excluded.scope,
1384
- owner = excluded.owner,
1385
- task_id = excluded.task_id,
1386
- reason = excluded.reason,
1387
- expires_at = excluded.expires_at
1388
- `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
1389
- addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
1390
- return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
1391
- });
1697
+ let lock;
1698
+ try {
1699
+ lock = tx(db, () => {
1700
+ const t = now();
1701
+ if (taskId) {
1702
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
1703
+ if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
1704
+ assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
1705
+ }
1706
+ const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
1707
+ const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
1708
+ if (conflict) {
1709
+ throw new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
1710
+ resource: requested.base_resource,
1711
+ scope: requested.scope,
1712
+ lock_resource: conflict.resource,
1713
+ lock_scope: lockScope(conflict),
1714
+ owner: conflict.owner,
1715
+ expires_at: iso(conflict.expires_at)
1716
+ });
1717
+ }
1718
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
1719
+ db.prepare(`
1720
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
1721
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1722
+ ON CONFLICT(resource) DO UPDATE SET
1723
+ base_resource = excluded.base_resource,
1724
+ scope = excluded.scope,
1725
+ owner = excluded.owner,
1726
+ task_id = excluded.task_id,
1727
+ reason = excluded.reason,
1728
+ expires_at = excluded.expires_at
1729
+ `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
1730
+ addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
1731
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
1732
+ });
1733
+ } catch (err) {
1734
+ notifyTaskOwnerConflict(ctx, err);
1735
+ throw err;
1736
+ }
1392
1737
  printResult(ctx, lock, (data) => `locked ${lockLabel(data)} by ${data.owner} until ${iso(data.expires_at)}`);
1393
1738
  }
1394
1739
 
@@ -1523,7 +1868,7 @@ async function eventTail(ctx, args) {
1523
1868
 
1524
1869
  async function cmdStatus(ctx, args) {
1525
1870
  const opts = parseOpts(args);
1526
- const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
1871
+ const identity = resolveTargetPeer(ctx, opts, 'peer', 'shell');
1527
1872
  const peer = identity.id;
1528
1873
  const data = statusSummary(ctx, peer, identity);
1529
1874
  printResult(ctx, data, (s) => renderStatusSummary(s, peer));
@@ -1532,7 +1877,7 @@ async function cmdStatus(ctx, args) {
1532
1877
  async function cmdState(ctx, args) {
1533
1878
  if (wantsHelp(args)) return helpState();
1534
1879
  const opts = parseOpts(args, { arrays: ['resource'] });
1535
- const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
1880
+ const identity = resolveTargetPeer(ctx, opts, 'peer', 'shell');
1536
1881
  const peer = identity.id;
1537
1882
  const resources = normalizeStateResources(opts.resource || opts.resources || []);
1538
1883
  const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null, scope: opts.scope || null });
@@ -1588,6 +1933,7 @@ async function cmdUp(ctx, args) {
1588
1933
  if (r.hooks.claudeInstalled) lines.push('Claude Code hooks installed');
1589
1934
  if (r.hooks.codexInstalled) lines.push('Codex hooks installed');
1590
1935
  if (r.detected.length) lines.push(`detected: ${r.detected.map((s) => s.peerId).join(', ')}`);
1936
+ if (r.warnings?.length) lines.push(...r.warnings.map((warning) => `warning: ${warning}`));
1591
1937
  lines.push('web: run hcc web when you need browser terminal control');
1592
1938
  return lines.join('\n');
1593
1939
  });
@@ -1604,31 +1950,42 @@ async function prepareLocalBus(ctx, opts = {}) {
1604
1950
 
1605
1951
  const hooks = { claudeInstalled: false, codexInstalled: false };
1606
1952
  const shims = { installed: [], skipped: [], pathUpdated: false, rcFile: null };
1953
+ const warnings = [];
1607
1954
  try {
1608
- const { verifyClaudeHooks, installClaudeHooks,
1609
- verifyCodexHooks, installCodexHooks,
1610
- installShims, installPathEntry } = await loadSetup();
1611
- if (!verifyClaudeHooks()) {
1612
- installClaudeHooks(commandPath());
1613
- hooks.claudeInstalled = true;
1614
- }
1615
- if (!verifyCodexHooks()) {
1616
- try {
1617
- installCodexHooks(commandPath());
1955
+ const setup = await loadSetup();
1956
+ try {
1957
+ if (!setup.verifyClaudeHooks()) {
1958
+ setup.installClaudeHooks(commandPath());
1959
+ hooks.claudeInstalled = true;
1960
+ }
1961
+ } catch (err) {
1962
+ warnings.push(`Claude Code hooks installation failed: ${err.message}`);
1963
+ }
1964
+ try {
1965
+ if (!setup.verifyCodexHooks()) {
1966
+ setup.installCodexHooks(commandPath());
1618
1967
  hooks.codexInstalled = true;
1619
- } catch {}
1968
+ }
1969
+ } catch (err) {
1970
+ warnings.push(`Codex hooks installation failed: ${err.message}`);
1620
1971
  }
1621
1972
  if (opts.installShims) {
1622
- const result = installShims(commandPath());
1623
- shims.installed = result.installed;
1624
- shims.skipped = result.skipped;
1625
- if (result.installed.length) {
1626
- const pathResult = installPathEntry();
1627
- shims.pathUpdated = !pathResult.alreadyPresent;
1628
- shims.rcFile = pathResult.rcFile;
1973
+ try {
1974
+ const result = setup.installShims(commandPath());
1975
+ shims.installed = result.installed;
1976
+ shims.skipped = result.skipped;
1977
+ if (result.installed.length) {
1978
+ const pathResult = setup.installPathEntry();
1979
+ shims.pathUpdated = !pathResult.alreadyPresent;
1980
+ shims.rcFile = pathResult.rcFile;
1981
+ }
1982
+ } catch (err) {
1983
+ warnings.push(`shim installation failed: ${err.message}`);
1629
1984
  }
1630
1985
  }
1631
- } catch {}
1986
+ } catch (err) {
1987
+ warnings.push(`local integration setup failed: ${err.message}`);
1988
+ }
1632
1989
 
1633
1990
  const detected = [];
1634
1991
  if (!opts['no-discover']) {
@@ -1638,7 +1995,7 @@ async function prepareLocalBus(ctx, opts = {}) {
1638
1995
  ...scanClaudeSessions(),
1639
1996
  ...scanCodexSessions(),
1640
1997
  ...scanProcesses(),
1641
- ].filter((s) => s.hccRoot === ctx.root);
1998
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
1642
1999
  const byId = new Map();
1643
2000
  for (const s of found) {
1644
2001
  if (!byId.has(s.peerId)) byId.set(s.peerId, s);
@@ -1671,7 +2028,8 @@ async function prepareLocalBus(ctx, opts = {}) {
1671
2028
  guidance,
1672
2029
  hooks,
1673
2030
  shims,
1674
- detected
2031
+ detected,
2032
+ warnings
1675
2033
  };
1676
2034
  }
1677
2035
 
@@ -1727,7 +2085,10 @@ async function startWebBackground(ctx, args) {
1727
2085
  validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
1728
2086
  validateWebTokenOpts(opts);
1729
2087
  ensureTmuxAvailable({ autoInstall: true });
1730
- const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
2088
+ const setup = await prepareLocalBus(ctx, {
2089
+ ...opts,
2090
+ installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
2091
+ });
1731
2092
  registerProject(ctx);
1732
2093
 
1733
2094
  const existing = await readHealthyGlobalRuntime();
@@ -1851,6 +2212,9 @@ function printWebRuntime(ctx, runtime, opts = {}) {
1851
2212
  lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
1852
2213
  }
1853
2214
  }
2215
+ if (opts.setup?.warnings?.length) {
2216
+ lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
2217
+ }
1854
2218
  lines.push(`stop: ${r.stop}`);
1855
2219
  return lines.join('\n');
1856
2220
  });
@@ -1885,6 +2249,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
1885
2249
  const projectContexts = new Map();
1886
2250
  const prepared = await prepareLocalBus(ctx, opts);
1887
2251
 
2252
+ function newSessionActionToken() {
2253
+ return makeWebToken({ token: null });
2254
+ }
2255
+
1888
2256
  function rememberProject(projectCtx) {
1889
2257
  const normalized = contextForProject(projectCtx.root, projectCtx.dbPath, { cwd: projectCtx.cwd, json: ctx.json });
1890
2258
  projectContexts.set(normalized.root, normalized);
@@ -1894,7 +2262,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
1894
2262
 
1895
2263
  function knownProjects() {
1896
2264
  const rows = readProjectRegistry();
1897
- if (!rows.some((p) => p.root === ctx.root)) rows.unshift(projectRecord(ctx));
2265
+ if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
1898
2266
  for (const project of rows) {
1899
2267
  if (!projectContexts.has(project.root)) {
1900
2268
  projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
@@ -1919,7 +2287,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
1919
2287
  }
1920
2288
 
1921
2289
  function sessionsForProject(projectCtx) {
1922
- return [...sessions.values()].filter((session) => session.root === projectCtx.root);
2290
+ return [...sessions.values()].filter((session) => sameResolvedPath(session.root, projectCtx.root));
1923
2291
  }
1924
2292
 
1925
2293
  function getSession(projectCtx, id, db = null) {
@@ -1936,6 +2304,38 @@ async function cmdWeb(ctx, args, startMeta = {}) {
1936
2304
  return null;
1937
2305
  }
1938
2306
 
2307
+ function readActionToken(input, req) {
2308
+ const headerToken = req.headers['x-hcc-session-token'];
2309
+ return String(input.action_token || input.actionToken || headerToken || '').trim();
2310
+ }
2311
+
2312
+ function resolveWebActionSession(projectCtx, peer, input, req) {
2313
+ const db = connect(projectCtx);
2314
+ let session;
2315
+ try {
2316
+ session = getSession(projectCtx, peer, db);
2317
+ } finally {
2318
+ db.close();
2319
+ }
2320
+ if (!session || session.status !== 'running') {
2321
+ throw new CliError('PEER_IDENTITY_REQUIRED', `Web peer action requires a running managed session for ${peer}`, { peer });
2322
+ }
2323
+ const actorPeer = session.peerId || peer;
2324
+ if (actorPeer !== peer && session.id !== peer) {
2325
+ throw new CliError('PEER_IDENTITY_MISMATCH', `Web peer action target ${peer} does not match managed session ${actorPeer}`, {
2326
+ peer,
2327
+ actor_peer: actorPeer,
2328
+ session_id: session.id
2329
+ });
2330
+ }
2331
+ const expected = session.actionToken || '';
2332
+ const provided = readActionToken(input, req);
2333
+ if (!expected || provided !== expected) {
2334
+ throw new CliError('PEER_IDENTITY_REQUIRED', `Web peer action for ${peer} requires the managed session action token`, { peer });
2335
+ }
2336
+ return actorPeer;
2337
+ }
2338
+
1939
2339
  function knownPeerIds(projectCtx) {
1940
2340
  const db = connect(projectCtx);
1941
2341
  try {
@@ -1977,6 +2377,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
1977
2377
  const session = {
1978
2378
  id,
1979
2379
  peerId: id,
2380
+ actionToken: newSessionActionToken(),
1980
2381
  root: pctx.root,
1981
2382
  ctx: pctx,
1982
2383
  kind: meta.kind || 'external',
@@ -2187,7 +2588,9 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2187
2588
  force: false,
2188
2589
  projectCtx: ctx,
2189
2590
  binding,
2190
- autoAttach: true
2591
+ autoAttach: true,
2592
+ auditActorPeer: 'web-runtime',
2593
+ auditSource: 'runtime'
2191
2594
  });
2192
2595
  attached.peers.add(row.id);
2193
2596
  if (session.peerId) attached.peers.add(session.peerId);
@@ -2331,6 +2734,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2331
2734
  binding,
2332
2735
  provider_session_known: Boolean(providerSessionLabel),
2333
2736
  provider_session_label: providerSessionLabel,
2737
+ action_token: session.actionToken || null,
2334
2738
  warning: session.warning || null
2335
2739
  };
2336
2740
  }
@@ -2355,6 +2759,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2355
2759
  return open;
2356
2760
  }
2357
2761
 
2762
+ function closeSessionClients(session) {
2763
+ if (!session?.clients?.size) return;
2764
+ for (const client of [...session.clients]) {
2765
+ try {
2766
+ if (client.readyState === client.OPEN || client.readyState === 1) client.close(1001, 'runtime stopping');
2767
+ else if (typeof client.terminate === 'function') client.terminate();
2768
+ } catch {
2769
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
2770
+ }
2771
+ }
2772
+ }
2773
+
2358
2774
  function detachTmuxSession(session, status = 'detached') {
2359
2775
  stopTmuxStream(session);
2360
2776
  if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
@@ -2376,10 +2792,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2376
2792
  OR runtime_session_id = ?
2377
2793
  OR (? IS NOT NULL AND runtime_target = ?)
2378
2794
  `).run(t, session.id, peerId, session.id, session.pane || null, session.pane || null);
2379
- addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, {
2795
+ addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, auditPayload({
2796
+ actor: peerId,
2797
+ target: peerId,
2798
+ source: session.auditSource || 'runtime',
2380
2799
  runtime_session_id: session.id,
2381
2800
  pane: session.pane
2382
- });
2801
+ }));
2383
2802
  } finally {
2384
2803
  db.close();
2385
2804
  }
@@ -2552,7 +2971,12 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2552
2971
 
2553
2972
  function addRebindCleanupFailedEvent(db, actor, payload) {
2554
2973
  if (!db) return;
2555
- addEvent(db, 'tmux.session.rebind_cleanup_failed', actor, null, payload);
2974
+ addEvent(db, 'tmux.session.rebind_cleanup_failed', actor, null, auditPayload({
2975
+ actor,
2976
+ target: payload.old_peer || payload.target_peer || null,
2977
+ admin: true,
2978
+ ...payload
2979
+ }));
2556
2980
  }
2557
2981
 
2558
2982
  function oldTmuxRebindTarget(projectCtx, oldTarget, newTarget) {
@@ -2732,7 +3156,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2732
3156
  });
2733
3157
  }
2734
3158
  if (db) {
2735
- addEvent(db, 'tmux.session.rebind_cleanup_pending', actor, null, {
3159
+ addEvent(db, 'tmux.session.rebind_cleanup_pending', actor, null, auditPayload({
3160
+ actor,
3161
+ target: oldPeer,
3162
+ admin: true,
2736
3163
  old_peer: oldPeer,
2737
3164
  old_runtime_target: oldTarget,
2738
3165
  new_runtime_target: newTarget,
@@ -2741,7 +3168,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2741
3168
  expected_tmux_session: expectedSession,
2742
3169
  allowed_tmux_session: allowedSession,
2743
3170
  hcc_root: hccRoot || null
2744
- });
3171
+ }));
2745
3172
  }
2746
3173
  return {
2747
3174
  oldPeer,
@@ -2829,13 +3256,16 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2829
3256
  throw err;
2830
3257
  }
2831
3258
  if (db) {
2832
- addEvent(db, 'tmux.session.rebound', actor, null, {
3259
+ addEvent(db, 'tmux.session.rebound', actor, null, auditPayload({
3260
+ actor,
3261
+ target: oldPeer,
3262
+ admin: true,
2833
3263
  old_peer: oldPeer,
2834
3264
  old_runtime_target: oldTarget,
2835
3265
  new_runtime_target: newTarget,
2836
3266
  old_tmux_session: oldSessionName,
2837
3267
  new_tmux_session: newSessionName || null
2838
- });
3268
+ }));
2839
3269
  }
2840
3270
  return true;
2841
3271
  }
@@ -2851,6 +3281,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2851
3281
  const pctx = input.projectCtx || ctx;
2852
3282
  const id = input.id;
2853
3283
  if (!id) throw new CliError('BAD_REQUEST', 'id required');
3284
+ const actorPeer = requestActorPeer(input, id);
3285
+ const auditSource = requestSource(input, input.autoAttach ? 'runtime' : 'web');
2854
3286
  const info = tmuxPaneInfo(input.pane || null);
2855
3287
  if (info.dead) throw new CliError('TMUX_PANE_DEAD', `tmux pane is not running: ${info.pane}`);
2856
3288
 
@@ -2882,6 +3314,9 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2882
3314
  const session = {
2883
3315
  id,
2884
3316
  peerId: id,
3317
+ actorPeer,
3318
+ auditSource,
3319
+ actionToken: newSessionActionToken(),
2885
3320
  root: pctx.root,
2886
3321
  ctx: pctx,
2887
3322
  kind,
@@ -2968,7 +3403,16 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2968
3403
  });
2969
3404
  session.peerId = canonical.peer;
2970
3405
  session.binding = { ...canonical.binding };
2971
- addEvent(db, 'tmux.session.attached', id, null, { pane: info.pane, command, cwd, pid: info.pid });
3406
+ addEvent(db, 'tmux.session.attached', actorPeer, null, auditPayload({
3407
+ actor: actorPeer,
3408
+ target: session.peerId || id,
3409
+ source: auditSource,
3410
+ admin: actorPeer !== (session.peerId || id),
3411
+ pane: info.pane,
3412
+ command,
3413
+ cwd,
3414
+ pid: info.pid
3415
+ }));
2972
3416
  } catch (err) {
2973
3417
  stopTmuxStream(session);
2974
3418
  if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
@@ -2983,12 +3427,17 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2983
3427
  try {
2984
3428
  killOldTmuxForRebind(pctx, rebindOldPlan, session.peerId || id, eventDb);
2985
3429
  if (rebindOldPeer && rebindOldPeer !== id) {
2986
- addEvent(eventDb, 'provider.session.rebound', session.peerId || id, null, {
3430
+ const actor = actorPeer || session.peerId || id;
3431
+ addEvent(eventDb, 'provider.session.rebound', actor, null, auditPayload({
3432
+ actor,
3433
+ target: rebindOldPeer,
3434
+ source: auditSource,
3435
+ admin: true,
2987
3436
  from_peer: rebindOldPeer,
2988
3437
  to_peer: id,
2989
3438
  old_runtime_target: rebindOldTarget,
2990
3439
  new_runtime_target: info.pane
2991
- });
3440
+ }));
2992
3441
  }
2993
3442
  } finally {
2994
3443
  eventDb.close();
@@ -3053,6 +3502,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3053
3502
  const pctx = input.projectCtx || ctx;
3054
3503
  const kind = input.kind || 'shell';
3055
3504
  const id = input.id || nextProjectSessionId(pctx, kind);
3505
+ const actorPeer = requestActorPeer(input, id);
3506
+ const auditSource = requestSource(input, 'web');
3056
3507
  const role = input.role || 'peer';
3057
3508
  const command = input.command || defaultSessionCommand(kind);
3058
3509
  const cwd = path.resolve(input.cwd || pctx.root);
@@ -3124,11 +3575,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3124
3575
  hasSession = false;
3125
3576
  const db = connect(pctx);
3126
3577
  try {
3127
- addEvent(db, 'tmux.session.restarted', id, null, {
3578
+ addEvent(db, 'tmux.session.restarted', actorPeer, null, auditPayload({
3579
+ actor: actorPeer,
3580
+ target: id,
3581
+ source: auditSource,
3582
+ admin: true,
3128
3583
  reason,
3129
3584
  old_runtime_target: oldTarget,
3130
3585
  old_tmux_session: parkedName
3131
- });
3586
+ }));
3132
3587
  } finally {
3133
3588
  db.close();
3134
3589
  }
@@ -3213,7 +3668,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3213
3668
  try {
3214
3669
  const eventDb = connect(pctx);
3215
3670
  try {
3216
- killOldTmuxForRebind(pctx, oldInfo.plan, session.peerId || id, eventDb);
3671
+ killOldTmuxForRebind(pctx, oldInfo.plan, actorPeer, eventDb);
3217
3672
  } finally {
3218
3673
  eventDb.close();
3219
3674
  }
@@ -3257,6 +3712,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3257
3712
  pane: row.runtime_target,
3258
3713
  projectCtx,
3259
3714
  force: true,
3715
+ auditActorPeer: 'web-runtime',
3716
+ auditSource: 'runtime',
3260
3717
  binding: {
3261
3718
  provider: row.provider,
3262
3719
  provider_session_id: row.provider_session_id,
@@ -3277,6 +3734,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3277
3734
  const pctx = input.projectCtx || ctx;
3278
3735
  const kind = input.kind || 'shell';
3279
3736
  const id = input.id || nextProjectSessionId(pctx, kind);
3737
+ const actorPeer = requestActorPeer(input, id);
3738
+ const auditSource = requestSource(input, 'web');
3280
3739
  const key = sessionKey(pctx, id);
3281
3740
  if (sessions.has(key) && sessions.get(key).status === 'running') {
3282
3741
  return sessions.get(key);
@@ -3303,6 +3762,9 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3303
3762
  const session = {
3304
3763
  id,
3305
3764
  peerId: id,
3765
+ actorPeer,
3766
+ auditSource,
3767
+ actionToken: newSessionActionToken(),
3306
3768
  root: pctx.root,
3307
3769
  ctx: pctx,
3308
3770
  kind,
@@ -3343,7 +3805,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3343
3805
  }, Boolean(input.force));
3344
3806
  session.peerId = canonical.peer;
3345
3807
  session.binding = { ...canonical.binding };
3346
- addEvent(db, 'web.session.started', id, null, { command, cwd, pid: child.pid });
3808
+ addEvent(db, 'web.session.started', actorPeer, null, auditPayload({
3809
+ actor: actorPeer,
3810
+ target: session.peerId || id,
3811
+ source: auditSource,
3812
+ admin: actorPeer !== (session.peerId || id),
3813
+ command,
3814
+ cwd,
3815
+ pid: child.pid
3816
+ }));
3347
3817
  } finally {
3348
3818
  db.close();
3349
3819
  }
@@ -3358,7 +3828,12 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3358
3828
  const db = connect(pctx);
3359
3829
  try {
3360
3830
  db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
3361
- addEvent(db, 'web.session.exited', id, null, event);
3831
+ addEvent(db, 'web.session.exited', session.actorPeer || id, null, auditPayload({
3832
+ actor: session.actorPeer || id,
3833
+ target: session.peerId || id,
3834
+ source: session.auditSource || 'web',
3835
+ ...event
3836
+ }));
3362
3837
  } finally {
3363
3838
  db.close();
3364
3839
  }
@@ -3508,7 +3983,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3508
3983
  resource: url.searchParams.getAll('resource')
3509
3984
  }
3510
3985
  : await readJsonRequest(req);
3511
- sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));
3986
+ const actionInput = readOnly
3987
+ ? input
3988
+ : { ...input, actorPeer: resolveWebActionSession(reqCtx, peer, input, req) };
3989
+ sendJson(res, 200, webPeerAction(reqCtx, peer, action, actionInput));
3512
3990
  return;
3513
3991
  }
3514
3992
  // Detected sessions: peers registered via hooks/watcher but without PTY
@@ -3610,13 +4088,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3610
4088
  }
3611
4089
  if (req.method === 'POST' && url.pathname === '/api/sessions') {
3612
4090
  const input = await readJsonRequest(req);
3613
- const session = startSession({ ...input, projectCtx: reqCtx });
4091
+ const session = startSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
3614
4092
  sendJson(res, 200, { session: serializeSession(session) });
3615
4093
  return;
3616
4094
  }
3617
4095
  if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
3618
4096
  const input = await readJsonRequest(req);
3619
- const session = attachTmuxSession({ ...input, projectCtx: reqCtx });
4097
+ const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
3620
4098
  sendJson(res, 200, { session: serializeSession(session) });
3621
4099
  return;
3622
4100
  }
@@ -3644,7 +4122,16 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3644
4122
  writeSessionInput(session, data);
3645
4123
  const db = connect(session.ctx || reqCtx);
3646
4124
  try {
3647
- addEvent(db, 'web.session.input', id, null, { bytes: data.length, enter: input.enter !== false });
4125
+ addEvent(db, 'web.session.input', 'web', null, auditPayload({
4126
+ actor: 'web',
4127
+ target: session.peerId || id,
4128
+ source: 'web',
4129
+ admin: true,
4130
+ peer: session.peerId || id,
4131
+ runtime_session_id: session.id,
4132
+ bytes: data.length,
4133
+ enter: input.enter !== false
4134
+ }));
3648
4135
  } finally {
3649
4136
  db.close();
3650
4137
  }
@@ -3654,6 +4141,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3654
4141
  const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
3655
4142
  if (req.method === 'POST' && stopMatch) {
3656
4143
  const id = decodeURIComponent(stopMatch[1]);
4144
+ let stopInput = {};
4145
+ try { stopInput = await readJsonRequest(req); } catch {}
3657
4146
  const lookupDb = connect(reqCtx);
3658
4147
  let session;
3659
4148
  try {
@@ -3672,13 +4161,11 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3672
4161
  if (session.wrapperPid) { try { process.kill(session.wrapperPid, 'SIGTERM'); } catch {} }
3673
4162
  if (session.pid && session.pid !== session.wrapperPid) { try { process.kill(session.pid, 'SIGTERM'); } catch {} }
3674
4163
  } else if (session.type === 'tmux') {
3675
- let input = {};
3676
- try { input = await readJsonRequest(req); } catch {}
3677
4164
  let killPlan = null;
3678
4165
  const stopDb = connect(reqCtx);
3679
4166
  try {
3680
4167
  const peerId = resolveSessionPeerId(stopDb, session) || session.peerId || session.id;
3681
- if (input.kill_tmux) {
4168
+ if (stopInput.kill_tmux) {
3682
4169
  killPlan = safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null);
3683
4170
  }
3684
4171
  } finally {
@@ -3690,6 +4177,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3690
4177
  session.pty.kill();
3691
4178
  }
3692
4179
  }
4180
+ const eventDb = connect(reqCtx);
4181
+ try {
4182
+ const peerId = resolveSessionPeerId(eventDb, session) || session.peerId || id;
4183
+ addEvent(eventDb, 'web.session.stop_requested', 'web', null, auditPayload({
4184
+ actor: 'web',
4185
+ target: peerId,
4186
+ source: 'web',
4187
+ admin: true,
4188
+ peer: peerId,
4189
+ runtime_session_id: session.id,
4190
+ kill_tmux: Boolean(stopInput.kill_tmux)
4191
+ }));
4192
+ } finally {
4193
+ eventDb.close();
4194
+ }
3693
4195
  sendJson(res, 200, { session: serializeSession(session) });
3694
4196
  return;
3695
4197
  }
@@ -3699,7 +4201,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3699
4201
  const peerId = decodeURIComponent(detectedMsgMatch[1]);
3700
4202
  const input = await readJsonRequest(req);
3701
4203
  const body = String(input.body || '');
3702
- const sender = String(input.from || 'web');
4204
+ const sender = 'web';
3703
4205
  const taskId = input.task ? Number(input.task) : null;
3704
4206
  if (!body) { sendJson(res, 400, { ok: false, error: { code: 'BAD_REQUEST', message: 'body required' } }); return; }
3705
4207
  const db = connect(reqCtx);
@@ -3726,12 +4228,16 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3726
4228
  }
3727
4229
  db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now_, peerId);
3728
4230
  db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now_, peerId);
3729
- addEvent(db, 'peer.stopped', 'web', null, {
4231
+ addEvent(db, 'peer.stopped', 'web', null, auditPayload({
4232
+ actor: 'web',
4233
+ target: peerId,
4234
+ source: 'web',
4235
+ admin: true,
3730
4236
  peer: peerId,
3731
4237
  kill_tmux: Boolean(killPlan),
3732
4238
  tmux_session: killPlan?.session || null,
3733
4239
  runtime_target: killPlan?.runtime_target || null
3734
- });
4240
+ }));
3735
4241
  } finally {
3736
4242
  db.close();
3737
4243
  }
@@ -3745,7 +4251,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3745
4251
  try {
3746
4252
  const now_ = now();
3747
4253
  db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('running', now_, peerId);
3748
- addEvent(db, 'peer.restarted', 'web', null, { peer: peerId });
4254
+ addEvent(db, 'peer.restarted', 'web', null, auditPayload({
4255
+ actor: 'web',
4256
+ target: peerId,
4257
+ source: 'web',
4258
+ admin: true,
4259
+ peer: peerId
4260
+ }));
3749
4261
  } finally {
3750
4262
  db.close();
3751
4263
  }
@@ -3755,7 +4267,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3755
4267
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
3756
4268
  } catch (err) {
3757
4269
  const detail = err instanceof CliError || process.env.HCC_DEBUG ? err.message : 'internal server error';
3758
- sendJson(res, 500, { ok: false, error: { code: err.code || 'SERVER_ERROR', message: detail } });
4270
+ sendJson(res, webErrorStatus(err), { ok: false, error: { code: err.code || 'SERVER_ERROR', message: detail } });
3759
4271
  }
3760
4272
  });
3761
4273
 
@@ -3810,11 +4322,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3810
4322
  });
3811
4323
  });
3812
4324
 
4325
+ let shuttingDown = false;
3813
4326
  function shutdown() {
4327
+ if (shuttingDown) return;
4328
+ shuttingDown = true;
3814
4329
  clearRuntime(ctx);
3815
4330
  clearInterval(externalScanPoller);
3816
4331
  clearInterval(autoAttachPoller);
3817
4332
  for (const session of sessions.values()) {
4333
+ closeSessionClients(session);
3818
4334
  if (session.status !== 'running') continue;
3819
4335
  if (session.type === 'external') {
3820
4336
  try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
@@ -3827,7 +4343,28 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3827
4343
  try { session.pty.kill(); } catch {}
3828
4344
  }
3829
4345
  }
3830
- server.close(() => process.exit(0));
4346
+ try { wss.close(); } catch {}
4347
+ const terminateClients = setTimeout(() => {
4348
+ for (const session of sessions.values()) {
4349
+ for (const client of [...(session.clients || [])]) {
4350
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
4351
+ }
4352
+ }
4353
+ try { server.closeAllConnections?.(); } catch {}
4354
+ }, 250);
4355
+ const forceExit = setTimeout(() => process.exit(0), 1500);
4356
+ try {
4357
+ server.close(() => {
4358
+ clearTimeout(terminateClients);
4359
+ clearTimeout(forceExit);
4360
+ process.exit(0);
4361
+ });
4362
+ try { server.closeIdleConnections?.(); } catch {}
4363
+ } catch {
4364
+ clearTimeout(terminateClients);
4365
+ clearTimeout(forceExit);
4366
+ process.exit(0);
4367
+ }
3831
4368
  }
3832
4369
  process.once('SIGINT', shutdown);
3833
4370
  process.once('SIGTERM', shutdown);
@@ -3850,7 +4387,9 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3850
4387
  registerProject(ctx);
3851
4388
  const db = connect(ctx);
3852
4389
  try {
3853
- addEvent(db, startMeta.eventType || 'web.started', 'human', null, {
4390
+ addEvent(db, startMeta.eventType || 'web.started', 'human', null, auditPayload({
4391
+ actor: 'human',
4392
+ source: 'cli',
3854
4393
  root: ctx.root,
3855
4394
  db: ctx.dbPath,
3856
4395
  host,
@@ -3858,7 +4397,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
3858
4397
  requested_port: port,
3859
4398
  guidance: startMeta.guidance || prepared.guidance || null,
3860
4399
  runtime: runtimeFile
3861
- });
4400
+ }));
3862
4401
  } finally {
3863
4402
  db.close();
3864
4403
  }
@@ -3876,8 +4415,9 @@ async function cmdRun(ctx, args) {
3876
4415
  const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
3877
4416
  const opts = parseOpts(optArgs, { booleans: ['force', 'web-managed'] });
3878
4417
  validateOpts('run', opts, ['peer', 'kind', 'role', 'cwd', 'force']);
3879
- const id = required(opts, 'peer', 'HCC_PEER');
3880
4418
  const kind = opts.kind || 'other';
4419
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', kind);
4420
+ const id = identity.id;
3881
4421
  const role = opts.role || 'peer';
3882
4422
  const cwd = path.resolve(opts.cwd || ctx.cwd);
3883
4423
  const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
@@ -3895,7 +4435,12 @@ async function cmdRun(ctx, args) {
3895
4435
  capabilities: 'run-wrapper'
3896
4436
  });
3897
4437
  upsertCanonicalPeerBinding(db, binding, Boolean(opts.force));
3898
- addEvent(db, 'run.session.started', id, null, { command: [command, ...commandArgs].join(' '), cwd });
4438
+ addEvent(db, 'run.session.started', id, null, auditPayload({
4439
+ actor: id,
4440
+ target: id,
4441
+ command: [command, ...commandArgs].join(' '),
4442
+ cwd
4443
+ }));
3899
4444
  } finally {
3900
4445
  db.close();
3901
4446
  }
@@ -3915,7 +4460,11 @@ async function cmdRun(ctx, args) {
3915
4460
  const db2 = connect(ctx);
3916
4461
  try {
3917
4462
  db2.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
3918
- addEvent(db2, 'run.session.exited', id, null, exitCode);
4463
+ addEvent(db2, 'run.session.exited', id, null, auditPayload({
4464
+ actor: id,
4465
+ target: id,
4466
+ ...exitCode
4467
+ }));
3919
4468
  } finally {
3920
4469
  db2.close();
3921
4470
  }
@@ -3958,7 +4507,13 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
3958
4507
  capabilities: 'run-pty'
3959
4508
  });
3960
4509
  upsertCanonicalPeerBinding(db, binding, force);
3961
- addEvent(db, 'run.session.started', id, null, { command: [command, ...commandArgs].join(' '), cwd, webManaged: true });
4510
+ addEvent(db, 'run.session.started', id, null, auditPayload({
4511
+ actor: id,
4512
+ target: id,
4513
+ command: [command, ...commandArgs].join(' '),
4514
+ cwd,
4515
+ webManaged: true
4516
+ }));
3962
4517
  } finally {
3963
4518
  db.close();
3964
4519
  }
@@ -4075,7 +4630,11 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
4075
4630
  const db2 = connect(ctx);
4076
4631
  try {
4077
4632
  db2.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
4078
- addEvent(db2, 'run.session.exited', id, null, exitCode);
4633
+ addEvent(db2, 'run.session.exited', id, null, auditPayload({
4634
+ actor: id,
4635
+ target: id,
4636
+ ...exitCode
4637
+ }));
4079
4638
  } finally {
4080
4639
  db2.close();
4081
4640
  }
@@ -4200,13 +4759,22 @@ async function cmdHook(ctx, args) {
4200
4759
  SET last_seen_at = ?, status = COALESCE(?, status)
4201
4760
  WHERE id = ?
4202
4761
  `).run(now(), status, peerId);
4203
- addEvent(db, 'provider.session.merged', peerId, null, {
4762
+ addEvent(db, 'provider.session.merged', peerId, null, auditPayload({
4763
+ actor: peerId,
4764
+ target: peerId,
4765
+ source: 'hook',
4204
4766
  from_peer: previousPeer,
4205
4767
  provider: kind,
4206
4768
  session_id: sessionId || resumeId || null
4207
- });
4769
+ }));
4208
4770
  }
4209
- addEvent(db, `hook.${hookKey}`, peerId, null, { session_id: sessionId, cwd: hookCwd });
4771
+ addEvent(db, `hook.${hookKey}`, peerId, null, auditPayload({
4772
+ actor: peerId,
4773
+ target: peerId,
4774
+ source: 'hook',
4775
+ session_id: sessionId,
4776
+ cwd: hookCwd
4777
+ }));
4210
4778
  try {
4211
4779
  reconcileRunningPeerBindings(db, hookCtx, {
4212
4780
  inspectProcess: inspectProviderProcess,
@@ -4246,6 +4814,7 @@ async function cmdHook(ctx, args) {
4246
4814
  // ─── hcc install-hooks ───────────────────────────────────────────────────────
4247
4815
 
4248
4816
  async function cmdInstallHooks(ctx, args) {
4817
+ if (wantsHelp(args)) return helpInstallHooks();
4249
4818
  const opts = parseOpts(args, { booleans: ['uninstall', 'status'] });
4250
4819
  const { installClaudeHooks, uninstallClaudeHooks, verifyClaudeHooks,
4251
4820
  installCodexHooks, uninstallCodexHooks, verifyCodexHooks } = await loadSetup();
@@ -4275,11 +4844,20 @@ async function cmdInstallHooks(ctx, args) {
4275
4844
  `hooks installed: claude → ${cp}${cxp ? `, codex → ${cxp}` : ''}`);
4276
4845
  }
4277
4846
 
4847
+ function pathEntryRemovalMessage(pathEntry) {
4848
+ if (pathEntry.error) return `PATH entry not removed: ${pathEntry.error}`;
4849
+ if (pathEntry.missing) return `PATH entry not present (${pathEntry.rcFile} not found)`;
4850
+ if (pathEntry.removed === false) return `PATH entry not present in ${pathEntry.rcFile}`;
4851
+ return `PATH entry removed from ${pathEntry.rcFile}`;
4852
+ }
4853
+
4278
4854
  // ─── hcc shim ────────────────────────────────────────────────────────────────
4279
4855
 
4280
4856
  async function cmdShim(ctx, args) {
4281
4857
  const sub = args[0];
4282
- const { installShims, uninstallShims, verifyShims, installPathEntry, SHIM_DIR } = await loadSetup();
4858
+ const { installShims, uninstallShims, shimStatus, installPathEntry, uninstallPathEntry, SHIM_DIR } = await loadSetup();
4859
+
4860
+ if (wantsHelp(args)) return helpShim();
4283
4861
 
4284
4862
  if (sub === 'ensure') {
4285
4863
  const name = args[1];
@@ -4287,9 +4865,10 @@ async function cmdShim(ctx, args) {
4287
4865
  if (!['claude', 'codex'].includes(name) || !target) {
4288
4866
  throw new CliError('BAD_ARGS', 'Usage: hcc shim ensure claude|codex PATH');
4289
4867
  }
4290
- const result = installShims(commandPath());
4868
+ const realBin = args[3] || null;
4869
+ const result = installShims(commandPath(), realBin ? { realBins: { [name]: realBin } } : {});
4291
4870
  const changed = (result.changed || []).map((p) => path.resolve(p));
4292
- if (changed.includes(target)) {
4871
+ if (changed.some((p) => sameResolvedPath(p, target))) {
4293
4872
  process.exitCode = 75;
4294
4873
  return;
4295
4874
  }
@@ -4299,32 +4878,45 @@ async function cmdShim(ctx, args) {
4299
4878
  if (!sub || sub === 'install') {
4300
4879
  const hccBin = commandPath();
4301
4880
  const result = installShims(hccBin);
4302
- const { alreadyPresent, rcFile } = installPathEntry();
4303
4881
  const lines = [
4304
4882
  result.installed.length
4305
4883
  ? `shims installed:\n${result.installed.map((p) => ` ${p}`).join('\n')}`
4306
4884
  : 'no shims installed (claude/codex not found on PATH)',
4307
4885
  ];
4308
4886
  if (result.skipped.length) lines.push(`skipped: ${result.skipped.join(', ')}`);
4309
- if (!alreadyPresent) {
4310
- lines.push(`PATH updated in ${rcFile}`);
4311
- lines.push(`run: source ${rcFile} (or open a new terminal)`);
4312
- } else {
4313
- lines.push(`PATH entry already present in ${rcFile}`);
4887
+ if (result.installed.length) {
4888
+ const { alreadyPresent, rcFile } = installPathEntry();
4889
+ if (!alreadyPresent) {
4890
+ lines.push(`PATH updated in ${rcFile}`);
4891
+ lines.push(`run: source ${rcFile} (or open a new terminal)`);
4892
+ } else {
4893
+ lines.push(`PATH entry already present in ${rcFile}`);
4894
+ }
4314
4895
  }
4315
4896
  printResult(ctx, result, () => lines.join('\n'));
4316
4897
  return;
4317
4898
  }
4318
4899
  if (sub === 'uninstall') {
4319
4900
  const removed = uninstallShims();
4320
- printResult(ctx, { removed }, () => removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove');
4901
+ const pathEntry = uninstallPathEntry();
4902
+ printResult(ctx, { removed, path_entry: pathEntry }, () => [
4903
+ removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove',
4904
+ pathEntryRemovalMessage(pathEntry)
4905
+ ].join('\n'));
4321
4906
  return;
4322
4907
  }
4323
4908
  if (sub === 'status') {
4324
- const installed = verifyShims();
4325
- printResult(ctx, { installed, shim_dir: SHIM_DIR }, () =>
4326
- installed ? `shims installed in ${SHIM_DIR} ✓` : `shims not installed (run: hcc web)`
4327
- );
4909
+ const status = shimStatus();
4910
+ printResult(ctx, status, (r) => [
4911
+ `shim dir: ${r.shimDir}`,
4912
+ `claude: ${r.tools.claude.installed ? 'installed' : 'missing'} (${r.tools.claude.path})`,
4913
+ `codex: ${r.tools.codex.installed ? 'installed' : 'missing'} (${r.tools.codex.path})`,
4914
+ r.complete
4915
+ ? 'status: complete'
4916
+ : r.installed
4917
+ ? 'status: partial (run: hcc shim install)'
4918
+ : 'status: not installed (run: hcc shim install)'
4919
+ ].join('\n'));
4328
4920
  return;
4329
4921
  }
4330
4922
  throw new CliError('BAD_ARGS', `Unknown shim subcommand: ${sub}`);
@@ -4350,7 +4942,7 @@ async function cmdSetup(ctx, args) {
4350
4942
 
4351
4943
  // 2. Install Claude Code + Codex hooks
4352
4944
  const { installClaudeHooks, verifyClaudeHooks, installCodexHooks, verifyCodexHooks,
4353
- installShims, verifyShims, installPathEntry } = await loadSetup();
4945
+ installShims, installPathEntry } = await loadSetup();
4354
4946
  const hccBin = commandPath();
4355
4947
 
4356
4948
  if (!verifyClaudeHooks()) {
@@ -4368,20 +4960,23 @@ async function cmdSetup(ctx, args) {
4368
4960
  }
4369
4961
 
4370
4962
  // 3. Install shims
4371
- if (!verifyShims()) {
4372
- const result = installShims(hccBin);
4373
- if (result.installed.length) {
4374
- log(`✓ shims installed → ${result.installed.join(', ')}`);
4963
+ const result = installShims(hccBin);
4964
+ if (result.installed.length) {
4965
+ if (result.changed.length) {
4966
+ log(`✓ shims installed → ${result.changed.join(', ')}`);
4375
4967
  } else {
4376
- log('⚠ shims: claude/codex not found on PATH — skipped');
4968
+ log(`✓ shims already installed → ${result.installed.join(', ')}`);
4377
4969
  }
4970
+ if (result.skipped.length) log(`⚠ shims skipped: ${result.skipped.join(', ')}`);
4378
4971
  const { alreadyPresent, rcFile } = installPathEntry();
4379
4972
  if (!alreadyPresent) {
4380
4973
  log(`✓ PATH updated in ${rcFile}`);
4381
4974
  log(` run: source ${rcFile} (or open a new terminal)`);
4975
+ } else {
4976
+ log(`✓ PATH entry already present in ${rcFile}`);
4382
4977
  }
4383
4978
  } else {
4384
- log('✓ shims already installed');
4979
+ log('⚠ shims: claude/codex not found on PATH — skipped');
4385
4980
  }
4386
4981
 
4387
4982
  log('\nDone. Run `hcc web` for the default coordinated terminal experience.');
@@ -4461,13 +5056,15 @@ async function cmdUninstall(ctx, args) {
4461
5056
  lines.push('runtime not running');
4462
5057
  }
4463
5058
 
4464
- const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims } = await loadSetup();
5059
+ const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
4465
5060
  const claude = uninstallClaudeHooks();
4466
5061
  const codex = uninstallCodexHooks();
4467
5062
  const shims = uninstallShims();
5063
+ const pathEntry = uninstallPathEntry();
4468
5064
  lines.push(claude ? 'Claude Code hooks removed' : 'Claude Code hooks not found');
4469
5065
  lines.push(codex ? 'Codex hooks removed' : 'Codex hooks not found');
4470
5066
  lines.push(shims.length ? `shims removed: ${shims.join(', ')}` : 'shims not found');
5067
+ lines.push(pathEntryRemovalMessage(pathEntry));
4471
5068
 
4472
5069
  let guidance = [];
4473
5070
  let purged = false;
@@ -4485,7 +5082,7 @@ async function cmdUninstall(ctx, args) {
4485
5082
  lines.push('project data kept; run hcc uninstall --purge --yes to remove .hello-cc and guidance blocks');
4486
5083
  }
4487
5084
 
4488
- printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, purge: purged, guidance }, () => lines.join('\n'));
5085
+ printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
4489
5086
  }
4490
5087
 
4491
5088
  // ─── hcc scan ────────────────────────────────────────────────────────────────
@@ -4498,7 +5095,7 @@ async function cmdScan(ctx, args) {
4498
5095
  ...scanClaudeSessions(),
4499
5096
  ...scanCodexSessions(),
4500
5097
  ...scanProcesses(),
4501
- ].filter((s) => s.hccRoot === ctx.root);
5098
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
4502
5099
 
4503
5100
  // Deduplicate by peerId
4504
5101
  const byId = new Map();
@@ -4830,13 +5427,16 @@ async function cmdTmux(ctx, args) {
4830
5427
  `).run(t, candidate.peer, candidate.runtime_target);
4831
5428
  db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('detached', t, candidate.peer);
4832
5429
  }
4833
- addEvent(db, 'tmux.session.gc', actor, null, {
5430
+ addEvent(db, 'tmux.session.gc', actor, null, auditPayload({
5431
+ actor,
5432
+ target: candidate.peer,
5433
+ admin: true,
4834
5434
  peer: candidate.peer,
4835
5435
  tmux_session: candidate.session,
4836
5436
  runtime_target: candidate.runtime_target,
4837
5437
  reason: candidate.reason,
4838
5438
  older_than_days: plan.older_than_days
4839
- });
5439
+ }));
4840
5440
  removed.push(candidate);
4841
5441
  });
4842
5442
  }