@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/CHANGELOG.md +104 -0
- package/README.md +33 -9
- package/README.zh-CN.md +31 -9
- package/assets/screenshots/web-console.png +0 -0
- package/bin/hcc.mjs +749 -149
- package/docs/commands.md +13 -6
- package/docs/commands.zh-CN.md +11 -6
- package/docs/guide.md +10 -5
- package/docs/guide.zh-CN.md +8 -5
- package/lib/cli-runtime.mjs +2 -1
- package/lib/core/coordination/tasks.mjs +17 -1
- package/lib/core/coordination/timeline.mjs +11 -1
- package/lib/core/peers/identity.mjs +10 -2
- package/lib/core/sessions/launch.mjs +1 -0
- package/lib/integrations/shims/script.mjs +42 -22
- package/lib/integrations/shims.mjs +45 -6
- package/lib/setup.mjs +1 -0
- package/lib/shell-path.mjs +31 -16
- package/lib/ui/help.mjs +36 -1
- package/lib/web/peer-actions.mjs +42 -6
- package/lib/web/ui-template.mjs +5 -3
- package/package.json +1 -1
- package/scripts/regression.mjs +821 -97
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
throw new CliError('
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
}
|
|
1968
|
+
}
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
warnings.push(`Codex hooks installation failed: ${err.message}`);
|
|
1620
1971
|
}
|
|
1621
1972
|
if (opts.installShims) {
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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,
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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',
|
|
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,
|
|
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
|
-
|
|
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',
|
|
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 (
|
|
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 =
|
|
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, {
|
|
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,
|
|
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
|
-
|
|
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, {
|
|
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,
|
|
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, {
|
|
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,
|
|
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, {
|
|
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,
|
|
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
|
|
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.
|
|
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 (
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
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
|
-
|
|
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
|
|
4325
|
-
printResult(ctx,
|
|
4326
|
-
|
|
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,
|
|
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
|
-
|
|
4372
|
-
|
|
4373
|
-
if (result.
|
|
4374
|
-
log(`✓ shims installed → ${result.
|
|
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(
|
|
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('
|
|
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
|
|
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
|
}
|