@logicseek/hello-cc 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +20 -7
- package/README.zh-CN.md +15 -6
- package/bin/hcc.mjs +383 -62
- package/docs/commands.md +16 -7
- package/docs/commands.zh-CN.md +14 -7
- package/docs/guide.md +24 -7
- package/docs/guide.zh-CN.md +20 -6
- package/lib/core/coordination/timeline.mjs +1 -0
- package/lib/core/sessions/launch.mjs +3 -0
- package/lib/integrations/shims/script.mjs +66 -24
- package/lib/integrations/shims.mjs +45 -6
- package/lib/runtime/client.mjs +1 -1
- package/lib/runtime/state.mjs +6 -3
- package/lib/setup.mjs +1 -0
- package/lib/shell-path.mjs +31 -16
- package/lib/ui/help.mjs +36 -1
- package/lib/web/http.mjs +8 -1
- package/package.json +1 -1
- package/scripts/regression.mjs +651 -48
package/bin/hcc.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import process from 'node:process';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
7
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
9
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
10
|
import { URL, fileURLToPath } from 'node:url';
|
|
@@ -227,6 +228,8 @@ const {
|
|
|
227
228
|
helpDown,
|
|
228
229
|
helpUpdate,
|
|
229
230
|
helpUninstall,
|
|
231
|
+
helpInstallHooks,
|
|
232
|
+
helpShim,
|
|
230
233
|
helpWeb
|
|
231
234
|
} = createHelpFunctions({
|
|
232
235
|
productName: PRODUCT_NAME,
|
|
@@ -279,7 +282,11 @@ function splitProcessArgs(line) {
|
|
|
279
282
|
|
|
280
283
|
function sameResolvedPath(a, b) {
|
|
281
284
|
if (!a || !b) return false;
|
|
282
|
-
|
|
285
|
+
function key(value) {
|
|
286
|
+
try { return fs.realpathSync(value); }
|
|
287
|
+
catch { return path.resolve(value); }
|
|
288
|
+
}
|
|
289
|
+
return key(a) === key(b);
|
|
283
290
|
}
|
|
284
291
|
|
|
285
292
|
const {
|
|
@@ -731,6 +738,7 @@ async function cmdTask(ctx, args) {
|
|
|
731
738
|
const sub = args[0];
|
|
732
739
|
if (!sub || wantsHelp(args)) return helpTask();
|
|
733
740
|
if (sub === 'create') return taskCreate(ctx, args.slice(1));
|
|
741
|
+
if (sub === 'dispatch') return taskDispatch(ctx, args.slice(1));
|
|
734
742
|
if (sub === 'list') return taskList(ctx, args.slice(1));
|
|
735
743
|
if (sub === 'claim') return taskClaim(ctx, args.slice(1));
|
|
736
744
|
if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
|
|
@@ -772,6 +780,201 @@ async function taskCreate(ctx, args) {
|
|
|
772
780
|
(data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
|
|
773
781
|
}
|
|
774
782
|
|
|
783
|
+
function dispatchPromptText(task, customMessage = null) {
|
|
784
|
+
if (customMessage) return customMessage;
|
|
785
|
+
return [
|
|
786
|
+
`Please pick up hello-cc task #${task.id}: ${task.title}.`,
|
|
787
|
+
`Run hcc task claim --id ${task.id}, then follow project coordination rules, create a handoff, and mark the task done when finished.`
|
|
788
|
+
].join(' ');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function currentOwnedTaskForPeer(db, peer) {
|
|
792
|
+
return db.prepare(`
|
|
793
|
+
SELECT *
|
|
794
|
+
FROM tasks
|
|
795
|
+
WHERE owner = ?
|
|
796
|
+
AND status IN ('claimed', 'running', 'review', 'blocked')
|
|
797
|
+
ORDER BY
|
|
798
|
+
CASE status
|
|
799
|
+
WHEN 'running' THEN 0
|
|
800
|
+
WHEN 'claimed' THEN 1
|
|
801
|
+
WHEN 'review' THEN 2
|
|
802
|
+
WHEN 'blocked' THEN 3
|
|
803
|
+
ELSE 4
|
|
804
|
+
END,
|
|
805
|
+
priority ASC,
|
|
806
|
+
id ASC
|
|
807
|
+
LIMIT 1
|
|
808
|
+
`).get(peer);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function findRuntimeSessionForPeer(runtimeData, peer) {
|
|
812
|
+
return (runtimeData?.sessions || []).find((session) => {
|
|
813
|
+
const sessionPeer = session.peer_id || session.id;
|
|
814
|
+
return session.status === 'running' && (session.id === peer || sessionPeer === peer);
|
|
815
|
+
}) || null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function sessionLooksProviderInteractive(session) {
|
|
819
|
+
if (!['claude', 'codex'].includes(session?.kind)) return false;
|
|
820
|
+
if (session.provider_session_known) return true;
|
|
821
|
+
const command = String(session.command || '');
|
|
822
|
+
if (/^tmux\s+%/.test(command)) return false;
|
|
823
|
+
return /\b(?:claude|codex)(?:\s|$)/.test(command);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function taskDispatch(ctx, args) {
|
|
827
|
+
const opts = parseOpts(args, { booleans: ['force', 'no-inject'] });
|
|
828
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
829
|
+
const actor = identity.id;
|
|
830
|
+
const target = required(opts, 'to');
|
|
831
|
+
const requestedTaskId = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
832
|
+
const title = requestedTaskId ? (opts.title || null) : required(opts, 'title');
|
|
833
|
+
const body = opts.body || '';
|
|
834
|
+
const priority = intOpt(opts, 'priority', 100);
|
|
835
|
+
const customMessage = opts.message ? String(opts.message) : null;
|
|
836
|
+
const injectAllowed = !Boolean(opts['no-inject']);
|
|
837
|
+
|
|
838
|
+
let task = null;
|
|
839
|
+
let messageId = null;
|
|
840
|
+
let currentTask = null;
|
|
841
|
+
let previousAssignee = null;
|
|
842
|
+
const db = connect(ctx);
|
|
843
|
+
try {
|
|
844
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
845
|
+
const t = now();
|
|
846
|
+
task = tx(db, () => {
|
|
847
|
+
if (requestedTaskId) {
|
|
848
|
+
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
|
|
849
|
+
if (!existing) throw new CliError('NOT_FOUND', `Task #${requestedTaskId} does not exist`);
|
|
850
|
+
if (['done', 'abandoned'].includes(existing.status)) {
|
|
851
|
+
throw new CliError('BAD_STATE', `Task #${requestedTaskId} is ${existing.status}`);
|
|
852
|
+
}
|
|
853
|
+
if (existing.owner && existing.owner !== target) {
|
|
854
|
+
throw new CliError('TASK_OWNED', `Task #${requestedTaskId} is owned by ${existing.owner}`, {
|
|
855
|
+
owner: existing.owner,
|
|
856
|
+
task_id: requestedTaskId,
|
|
857
|
+
attempted_by: actor,
|
|
858
|
+
target
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
previousAssignee = existing.assignee || null;
|
|
862
|
+
db.prepare('UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?').run(target, t, requestedTaskId);
|
|
863
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
|
|
864
|
+
}
|
|
865
|
+
const info = db.prepare(`
|
|
866
|
+
INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
867
|
+
VALUES (?, ?, 'pending', ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
|
868
|
+
`).run(title, body, target, priority, actor, t, t);
|
|
869
|
+
const taskId = Number(info.lastInsertRowid);
|
|
870
|
+
addEvent(db, 'task.created', actor, taskId, { title, assignee: target, priority, parent_id: null, team_role: null });
|
|
871
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
872
|
+
});
|
|
873
|
+
const durableMessage = dispatchPromptText(task, customMessage);
|
|
874
|
+
messageId = sendMessage(db, actor, target, task.id, 'task', durableMessage);
|
|
875
|
+
currentTask = currentOwnedTaskForPeer(db, target);
|
|
876
|
+
} finally {
|
|
877
|
+
db.close();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const durableMessage = dispatchPromptText(task, customMessage);
|
|
881
|
+
let session = null;
|
|
882
|
+
let injected = false;
|
|
883
|
+
let injectionReason = injectAllowed ? 'runtime_unavailable' : 'no_inject';
|
|
884
|
+
const busyTask = currentTask && Number(currentTask.id) !== Number(task.id) ? currentTask : null;
|
|
885
|
+
if (injectAllowed) {
|
|
886
|
+
let runtime = null;
|
|
887
|
+
let runtimeData = null;
|
|
888
|
+
try {
|
|
889
|
+
runtime = readRuntime(ctx);
|
|
890
|
+
runtimeData = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
if (!(err instanceof CliError) || !['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) throw err;
|
|
893
|
+
}
|
|
894
|
+
session = runtimeData ? findRuntimeSessionForPeer(runtimeData, target) : null;
|
|
895
|
+
if (!session) {
|
|
896
|
+
injectionReason = runtimeData ? 'session_not_running' : 'runtime_unavailable';
|
|
897
|
+
} else if (!customMessage && !sessionLooksProviderInteractive(session)) {
|
|
898
|
+
injectionReason = 'unsupported_session_kind';
|
|
899
|
+
} else if (busyTask && !Boolean(opts.force)) {
|
|
900
|
+
injectionReason = 'target_busy';
|
|
901
|
+
} else {
|
|
902
|
+
try {
|
|
903
|
+
await injectPeer(ctx, target, durableMessage, true, runtime, actor);
|
|
904
|
+
injected = true;
|
|
905
|
+
injectionReason = 'injected';
|
|
906
|
+
} catch (err) {
|
|
907
|
+
if (!(err instanceof CliError)) throw err;
|
|
908
|
+
if (['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
|
|
909
|
+
injectionReason = 'runtime_unavailable';
|
|
910
|
+
} else if (['NOT_FOUND', 'SESSION_NOT_RUNNING'].includes(err.code)) {
|
|
911
|
+
injectionReason = 'session_not_running';
|
|
912
|
+
} else {
|
|
913
|
+
throw err;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const eventDb = connect(ctx);
|
|
920
|
+
try {
|
|
921
|
+
addEvent(eventDb, 'task.dispatched', actor, task.id, auditPayload({
|
|
922
|
+
actor,
|
|
923
|
+
target,
|
|
924
|
+
source: 'cli',
|
|
925
|
+
admin: actor !== target,
|
|
926
|
+
peer: target,
|
|
927
|
+
title: task.title,
|
|
928
|
+
message_id: messageId,
|
|
929
|
+
injected,
|
|
930
|
+
delivery: injected ? 'message+inject' : 'message-only',
|
|
931
|
+
injection_reason: injectionReason,
|
|
932
|
+
session_id: session?.id || null,
|
|
933
|
+
session_kind: session?.kind || null,
|
|
934
|
+
previous_assignee: previousAssignee,
|
|
935
|
+
blocked_by_task_id: busyTask?.id || null
|
|
936
|
+
}));
|
|
937
|
+
} finally {
|
|
938
|
+
eventDb.close();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const result = {
|
|
942
|
+
task,
|
|
943
|
+
target,
|
|
944
|
+
message_id: messageId,
|
|
945
|
+
message: durableMessage,
|
|
946
|
+
injected,
|
|
947
|
+
delivery: injected ? 'message+inject' : 'message-only',
|
|
948
|
+
injection_reason: injectionReason,
|
|
949
|
+
session: session ? {
|
|
950
|
+
id: session.id,
|
|
951
|
+
peer_id: session.peer_id || session.id,
|
|
952
|
+
kind: session.kind,
|
|
953
|
+
status: session.status
|
|
954
|
+
} : null,
|
|
955
|
+
previous_assignee: previousAssignee,
|
|
956
|
+
blocked_by_task: busyTask ? {
|
|
957
|
+
id: busyTask.id,
|
|
958
|
+
status: busyTask.status,
|
|
959
|
+
title: busyTask.title
|
|
960
|
+
} : null
|
|
961
|
+
};
|
|
962
|
+
printResult(ctx, result, (data) => {
|
|
963
|
+
const base = `dispatched task #${data.task.id} to ${data.target} with message #${data.message_id}`;
|
|
964
|
+
if (data.injected) return `${base} and injected live input`;
|
|
965
|
+
if (data.injection_reason === 'target_busy' && data.blocked_by_task) {
|
|
966
|
+
return `${base} (not injected: ${data.target} already owns task #${data.blocked_by_task.id})`;
|
|
967
|
+
}
|
|
968
|
+
if (data.injection_reason === 'unsupported_session_kind' && data.session) {
|
|
969
|
+
return `${base} (not injected: managed ${data.session.kind} session needs an explicit shell-safe message)`;
|
|
970
|
+
}
|
|
971
|
+
if (data.injection_reason === 'session_not_running') return `${base} (not injected: target is not a running managed session)`;
|
|
972
|
+
if (data.injection_reason === 'runtime_unavailable') return `${base} (not injected: web runtime is unavailable)`;
|
|
973
|
+
if (data.injection_reason === 'no_inject') return `${base} (message only)`;
|
|
974
|
+
return `${base} (not injected: ${data.injection_reason})`;
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
775
978
|
async function taskList(ctx, args) {
|
|
776
979
|
const opts = parseOpts(args, { booleans: ['all'] });
|
|
777
980
|
const status = opts.status || null;
|
|
@@ -1195,7 +1398,7 @@ async function cmdAsk(ctx, args) {
|
|
|
1195
1398
|
const id = sendMessage(db, sender, recipient, taskId, kind, body);
|
|
1196
1399
|
let injected = false;
|
|
1197
1400
|
if (opts.inject) {
|
|
1198
|
-
await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime);
|
|
1401
|
+
await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime, sender);
|
|
1199
1402
|
injected = true;
|
|
1200
1403
|
}
|
|
1201
1404
|
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' : ''}`);
|
|
@@ -1215,19 +1418,32 @@ async function cmdBroadcast(ctx, args) {
|
|
|
1215
1418
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
1216
1419
|
const id = sendMessage(db, sender, 'all', taskId, kind, body);
|
|
1217
1420
|
let injected = 0;
|
|
1421
|
+
let skipped = 0;
|
|
1218
1422
|
if (opts.inject) {
|
|
1219
1423
|
const sessions = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
|
|
1220
1424
|
const running = (sessions.sessions || []).filter((session) => session.status === 'running');
|
|
1221
1425
|
for (const session of running) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1426
|
+
try {
|
|
1427
|
+
await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
|
|
1428
|
+
injected += 1;
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
if (err instanceof CliError && ['NOT_FOUND', 'SESSION_NOT_RUNNING', 'TMUX_ERROR'].includes(err.code)) {
|
|
1431
|
+
skipped += 1;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
throw err;
|
|
1435
|
+
}
|
|
1224
1436
|
}
|
|
1225
1437
|
}
|
|
1226
|
-
printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected }, (data) =>
|
|
1438
|
+
printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected, skipped }, (data) => {
|
|
1439
|
+
const injectedText = data.injected ? ` and injected ${data.injected} terminal(s)` : '';
|
|
1440
|
+
const skippedText = data.skipped ? `, skipped ${data.skipped} stale terminal(s)` : '';
|
|
1441
|
+
return `broadcast message #${data.id}${injectedText}${skippedText}`;
|
|
1442
|
+
});
|
|
1227
1443
|
}
|
|
1228
1444
|
|
|
1229
|
-
async function injectPeer(ctx, peer, text, enter = true, runtime = null) {
|
|
1230
|
-
const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
1445
|
+
async function injectPeer(ctx, peer, text, enter = true, runtime = null, auditActor = null) {
|
|
1446
|
+
const actor = auditActor || resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
1231
1447
|
const db = connect(ctx);
|
|
1232
1448
|
try {
|
|
1233
1449
|
addEvent(db, 'web.session.input.requested', actor, null, auditPayload({
|
|
@@ -1731,6 +1947,7 @@ async function cmdUp(ctx, args) {
|
|
|
1731
1947
|
if (r.hooks.claudeInstalled) lines.push('Claude Code hooks installed');
|
|
1732
1948
|
if (r.hooks.codexInstalled) lines.push('Codex hooks installed');
|
|
1733
1949
|
if (r.detected.length) lines.push(`detected: ${r.detected.map((s) => s.peerId).join(', ')}`);
|
|
1950
|
+
if (r.warnings?.length) lines.push(...r.warnings.map((warning) => `warning: ${warning}`));
|
|
1734
1951
|
lines.push('web: run hcc web when you need browser terminal control');
|
|
1735
1952
|
return lines.join('\n');
|
|
1736
1953
|
});
|
|
@@ -1747,31 +1964,42 @@ async function prepareLocalBus(ctx, opts = {}) {
|
|
|
1747
1964
|
|
|
1748
1965
|
const hooks = { claudeInstalled: false, codexInstalled: false };
|
|
1749
1966
|
const shims = { installed: [], skipped: [], pathUpdated: false, rcFile: null };
|
|
1967
|
+
const warnings = [];
|
|
1750
1968
|
try {
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1969
|
+
const setup = await loadSetup();
|
|
1970
|
+
try {
|
|
1971
|
+
if (!setup.verifyClaudeHooks()) {
|
|
1972
|
+
setup.installClaudeHooks(commandPath());
|
|
1973
|
+
hooks.claudeInstalled = true;
|
|
1974
|
+
}
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
warnings.push(`Claude Code hooks installation failed: ${err.message}`);
|
|
1977
|
+
}
|
|
1978
|
+
try {
|
|
1979
|
+
if (!setup.verifyCodexHooks()) {
|
|
1980
|
+
setup.installCodexHooks(commandPath());
|
|
1761
1981
|
hooks.codexInstalled = true;
|
|
1762
|
-
}
|
|
1982
|
+
}
|
|
1983
|
+
} catch (err) {
|
|
1984
|
+
warnings.push(`Codex hooks installation failed: ${err.message}`);
|
|
1763
1985
|
}
|
|
1764
1986
|
if (opts.installShims) {
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1987
|
+
try {
|
|
1988
|
+
const result = setup.installShims(commandPath());
|
|
1989
|
+
shims.installed = result.installed;
|
|
1990
|
+
shims.skipped = result.skipped;
|
|
1991
|
+
if (result.installed.length) {
|
|
1992
|
+
const pathResult = setup.installPathEntry();
|
|
1993
|
+
shims.pathUpdated = !pathResult.alreadyPresent;
|
|
1994
|
+
shims.rcFile = pathResult.rcFile;
|
|
1995
|
+
}
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
warnings.push(`shim installation failed: ${err.message}`);
|
|
1772
1998
|
}
|
|
1773
1999
|
}
|
|
1774
|
-
} catch {
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
warnings.push(`local integration setup failed: ${err.message}`);
|
|
2002
|
+
}
|
|
1775
2003
|
|
|
1776
2004
|
const detected = [];
|
|
1777
2005
|
if (!opts['no-discover']) {
|
|
@@ -1781,7 +2009,7 @@ async function prepareLocalBus(ctx, opts = {}) {
|
|
|
1781
2009
|
...scanClaudeSessions(),
|
|
1782
2010
|
...scanCodexSessions(),
|
|
1783
2011
|
...scanProcesses(),
|
|
1784
|
-
].filter((s) => s.hccRoot
|
|
2012
|
+
].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
|
|
1785
2013
|
const byId = new Map();
|
|
1786
2014
|
for (const s of found) {
|
|
1787
2015
|
if (!byId.has(s.peerId)) byId.set(s.peerId, s);
|
|
@@ -1814,7 +2042,8 @@ async function prepareLocalBus(ctx, opts = {}) {
|
|
|
1814
2042
|
guidance,
|
|
1815
2043
|
hooks,
|
|
1816
2044
|
shims,
|
|
1817
|
-
detected
|
|
2045
|
+
detected,
|
|
2046
|
+
warnings
|
|
1818
2047
|
};
|
|
1819
2048
|
}
|
|
1820
2049
|
|
|
@@ -1829,11 +2058,21 @@ function hccWebProcessMatches(line, ctx) {
|
|
|
1829
2058
|
sameResolvedPath(global.db, ctx.dbPath);
|
|
1830
2059
|
}
|
|
1831
2060
|
|
|
2061
|
+
function currentProcessAncestorPids(ppidByPid) {
|
|
2062
|
+
const ancestors = new Set();
|
|
2063
|
+
let pid = process.ppid;
|
|
2064
|
+
while (Number.isFinite(pid) && pid > 0 && !ancestors.has(pid)) {
|
|
2065
|
+
ancestors.add(pid);
|
|
2066
|
+
pid = ppidByPid.get(pid);
|
|
2067
|
+
}
|
|
2068
|
+
return ancestors;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1832
2071
|
async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
1833
2072
|
if (process.platform === 'win32') return;
|
|
1834
2073
|
let output = '';
|
|
1835
2074
|
try {
|
|
1836
|
-
output = spawnSync('ps', ['-eo', 'pid=,args='], {
|
|
2075
|
+
output = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], {
|
|
1837
2076
|
encoding: 'utf8',
|
|
1838
2077
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
1839
2078
|
}).stdout || '';
|
|
@@ -1841,15 +2080,25 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
|
1841
2080
|
return;
|
|
1842
2081
|
}
|
|
1843
2082
|
|
|
1844
|
-
const
|
|
2083
|
+
const rows = [];
|
|
2084
|
+
const ppidByPid = new Map();
|
|
1845
2085
|
for (const line of output.split('\n')) {
|
|
1846
2086
|
const trimmed = line.trim();
|
|
1847
2087
|
if (!trimmed) continue;
|
|
1848
|
-
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
2088
|
+
const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
1849
2089
|
if (!match) continue;
|
|
1850
2090
|
const pid = Number.parseInt(match[1], 10);
|
|
1851
|
-
|
|
1852
|
-
if (
|
|
2091
|
+
const ppid = Number.parseInt(match[2], 10);
|
|
2092
|
+
if (!Number.isFinite(pid)) continue;
|
|
2093
|
+
if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
|
|
2094
|
+
rows.push({ pid, args: match[3] });
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
const ancestorPids = currentProcessAncestorPids(ppidByPid);
|
|
2098
|
+
const pids = [];
|
|
2099
|
+
for (const row of rows) {
|
|
2100
|
+
if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
|
|
2101
|
+
if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
|
|
1853
2102
|
}
|
|
1854
2103
|
if (!pids.length) return;
|
|
1855
2104
|
|
|
@@ -1870,7 +2119,10 @@ async function startWebBackground(ctx, args) {
|
|
|
1870
2119
|
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
|
|
1871
2120
|
validateWebTokenOpts(opts);
|
|
1872
2121
|
ensureTmuxAvailable({ autoInstall: true });
|
|
1873
|
-
const setup = await prepareLocalBus(ctx, {
|
|
2122
|
+
const setup = await prepareLocalBus(ctx, {
|
|
2123
|
+
...opts,
|
|
2124
|
+
installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
|
|
2125
|
+
});
|
|
1874
2126
|
registerProject(ctx);
|
|
1875
2127
|
|
|
1876
2128
|
const existing = await readHealthyGlobalRuntime();
|
|
@@ -1994,6 +2246,9 @@ function printWebRuntime(ctx, runtime, opts = {}) {
|
|
|
1994
2246
|
lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
|
|
1995
2247
|
}
|
|
1996
2248
|
}
|
|
2249
|
+
if (opts.setup?.warnings?.length) {
|
|
2250
|
+
lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
|
|
2251
|
+
}
|
|
1997
2252
|
lines.push(`stop: ${r.stop}`);
|
|
1998
2253
|
return lines.join('\n');
|
|
1999
2254
|
});
|
|
@@ -2029,7 +2284,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
2029
2284
|
const prepared = await prepareLocalBus(ctx, opts);
|
|
2030
2285
|
|
|
2031
2286
|
function newSessionActionToken() {
|
|
2032
|
-
return
|
|
2287
|
+
return randomBytes(32).toString('base64url');
|
|
2033
2288
|
}
|
|
2034
2289
|
|
|
2035
2290
|
function rememberProject(projectCtx) {
|
|
@@ -2041,7 +2296,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
2041
2296
|
|
|
2042
2297
|
function knownProjects() {
|
|
2043
2298
|
const rows = readProjectRegistry();
|
|
2044
|
-
if (!rows.some((p) => p.root
|
|
2299
|
+
if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
|
|
2045
2300
|
for (const project of rows) {
|
|
2046
2301
|
if (!projectContexts.has(project.root)) {
|
|
2047
2302
|
projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
|
|
@@ -2066,7 +2321,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
2066
2321
|
}
|
|
2067
2322
|
|
|
2068
2323
|
function sessionsForProject(projectCtx) {
|
|
2069
|
-
return [...sessions.values()].filter((session) => session.root
|
|
2324
|
+
return [...sessions.values()].filter((session) => sameResolvedPath(session.root, projectCtx.root));
|
|
2070
2325
|
}
|
|
2071
2326
|
|
|
2072
2327
|
function getSession(projectCtx, id, db = null) {
|
|
@@ -2538,6 +2793,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
2538
2793
|
return open;
|
|
2539
2794
|
}
|
|
2540
2795
|
|
|
2796
|
+
function closeSessionClients(session) {
|
|
2797
|
+
if (!session?.clients?.size) return;
|
|
2798
|
+
for (const client of [...session.clients]) {
|
|
2799
|
+
try {
|
|
2800
|
+
if (client.readyState === client.OPEN || client.readyState === 1) client.close(1001, 'runtime stopping');
|
|
2801
|
+
else if (typeof client.terminate === 'function') client.terminate();
|
|
2802
|
+
} catch {
|
|
2803
|
+
try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2541
2808
|
function detachTmuxSession(session, status = 'detached') {
|
|
2542
2809
|
stopTmuxStream(session);
|
|
2543
2810
|
if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
|
|
@@ -4089,11 +4356,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4089
4356
|
});
|
|
4090
4357
|
});
|
|
4091
4358
|
|
|
4359
|
+
let shuttingDown = false;
|
|
4092
4360
|
function shutdown() {
|
|
4361
|
+
if (shuttingDown) return;
|
|
4362
|
+
shuttingDown = true;
|
|
4093
4363
|
clearRuntime(ctx);
|
|
4094
4364
|
clearInterval(externalScanPoller);
|
|
4095
4365
|
clearInterval(autoAttachPoller);
|
|
4096
4366
|
for (const session of sessions.values()) {
|
|
4367
|
+
closeSessionClients(session);
|
|
4097
4368
|
if (session.status !== 'running') continue;
|
|
4098
4369
|
if (session.type === 'external') {
|
|
4099
4370
|
try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
|
|
@@ -4106,7 +4377,28 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4106
4377
|
try { session.pty.kill(); } catch {}
|
|
4107
4378
|
}
|
|
4108
4379
|
}
|
|
4109
|
-
|
|
4380
|
+
try { wss.close(); } catch {}
|
|
4381
|
+
const terminateClients = setTimeout(() => {
|
|
4382
|
+
for (const session of sessions.values()) {
|
|
4383
|
+
for (const client of [...(session.clients || [])]) {
|
|
4384
|
+
try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
try { server.closeAllConnections?.(); } catch {}
|
|
4388
|
+
}, 250);
|
|
4389
|
+
const forceExit = setTimeout(() => process.exit(0), 1500);
|
|
4390
|
+
try {
|
|
4391
|
+
server.close(() => {
|
|
4392
|
+
clearTimeout(terminateClients);
|
|
4393
|
+
clearTimeout(forceExit);
|
|
4394
|
+
process.exit(0);
|
|
4395
|
+
});
|
|
4396
|
+
try { server.closeIdleConnections?.(); } catch {}
|
|
4397
|
+
} catch {
|
|
4398
|
+
clearTimeout(terminateClients);
|
|
4399
|
+
clearTimeout(forceExit);
|
|
4400
|
+
process.exit(0);
|
|
4401
|
+
}
|
|
4110
4402
|
}
|
|
4111
4403
|
process.once('SIGINT', shutdown);
|
|
4112
4404
|
process.once('SIGTERM', shutdown);
|
|
@@ -4556,6 +4848,7 @@ async function cmdHook(ctx, args) {
|
|
|
4556
4848
|
// ─── hcc install-hooks ───────────────────────────────────────────────────────
|
|
4557
4849
|
|
|
4558
4850
|
async function cmdInstallHooks(ctx, args) {
|
|
4851
|
+
if (wantsHelp(args)) return helpInstallHooks();
|
|
4559
4852
|
const opts = parseOpts(args, { booleans: ['uninstall', 'status'] });
|
|
4560
4853
|
const { installClaudeHooks, uninstallClaudeHooks, verifyClaudeHooks,
|
|
4561
4854
|
installCodexHooks, uninstallCodexHooks, verifyCodexHooks } = await loadSetup();
|
|
@@ -4585,11 +4878,20 @@ async function cmdInstallHooks(ctx, args) {
|
|
|
4585
4878
|
`hooks installed: claude → ${cp}${cxp ? `, codex → ${cxp}` : ''}`);
|
|
4586
4879
|
}
|
|
4587
4880
|
|
|
4881
|
+
function pathEntryRemovalMessage(pathEntry) {
|
|
4882
|
+
if (pathEntry.error) return `PATH entry not removed: ${pathEntry.error}`;
|
|
4883
|
+
if (pathEntry.missing) return `PATH entry not present (${pathEntry.rcFile} not found)`;
|
|
4884
|
+
if (pathEntry.removed === false) return `PATH entry not present in ${pathEntry.rcFile}`;
|
|
4885
|
+
return `PATH entry removed from ${pathEntry.rcFile}`;
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4588
4888
|
// ─── hcc shim ────────────────────────────────────────────────────────────────
|
|
4589
4889
|
|
|
4590
4890
|
async function cmdShim(ctx, args) {
|
|
4591
4891
|
const sub = args[0];
|
|
4592
|
-
const { installShims, uninstallShims,
|
|
4892
|
+
const { installShims, uninstallShims, shimStatus, installPathEntry, uninstallPathEntry, SHIM_DIR } = await loadSetup();
|
|
4893
|
+
|
|
4894
|
+
if (wantsHelp(args)) return helpShim();
|
|
4593
4895
|
|
|
4594
4896
|
if (sub === 'ensure') {
|
|
4595
4897
|
const name = args[1];
|
|
@@ -4597,9 +4899,10 @@ async function cmdShim(ctx, args) {
|
|
|
4597
4899
|
if (!['claude', 'codex'].includes(name) || !target) {
|
|
4598
4900
|
throw new CliError('BAD_ARGS', 'Usage: hcc shim ensure claude|codex PATH');
|
|
4599
4901
|
}
|
|
4600
|
-
const
|
|
4902
|
+
const realBin = args[3] || null;
|
|
4903
|
+
const result = installShims(commandPath(), realBin ? { realBins: { [name]: realBin } } : {});
|
|
4601
4904
|
const changed = (result.changed || []).map((p) => path.resolve(p));
|
|
4602
|
-
if (changed.
|
|
4905
|
+
if (changed.some((p) => sameResolvedPath(p, target))) {
|
|
4603
4906
|
process.exitCode = 75;
|
|
4604
4907
|
return;
|
|
4605
4908
|
}
|
|
@@ -4609,32 +4912,45 @@ async function cmdShim(ctx, args) {
|
|
|
4609
4912
|
if (!sub || sub === 'install') {
|
|
4610
4913
|
const hccBin = commandPath();
|
|
4611
4914
|
const result = installShims(hccBin);
|
|
4612
|
-
const { alreadyPresent, rcFile } = installPathEntry();
|
|
4613
4915
|
const lines = [
|
|
4614
4916
|
result.installed.length
|
|
4615
4917
|
? `shims installed:\n${result.installed.map((p) => ` ${p}`).join('\n')}`
|
|
4616
4918
|
: 'no shims installed (claude/codex not found on PATH)',
|
|
4617
4919
|
];
|
|
4618
4920
|
if (result.skipped.length) lines.push(`skipped: ${result.skipped.join(', ')}`);
|
|
4619
|
-
if (
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4921
|
+
if (result.installed.length) {
|
|
4922
|
+
const { alreadyPresent, rcFile } = installPathEntry();
|
|
4923
|
+
if (!alreadyPresent) {
|
|
4924
|
+
lines.push(`PATH updated in ${rcFile}`);
|
|
4925
|
+
lines.push(`run: source ${rcFile} (or open a new terminal)`);
|
|
4926
|
+
} else {
|
|
4927
|
+
lines.push(`PATH entry already present in ${rcFile}`);
|
|
4928
|
+
}
|
|
4624
4929
|
}
|
|
4625
4930
|
printResult(ctx, result, () => lines.join('\n'));
|
|
4626
4931
|
return;
|
|
4627
4932
|
}
|
|
4628
4933
|
if (sub === 'uninstall') {
|
|
4629
4934
|
const removed = uninstallShims();
|
|
4630
|
-
|
|
4935
|
+
const pathEntry = uninstallPathEntry();
|
|
4936
|
+
printResult(ctx, { removed, path_entry: pathEntry }, () => [
|
|
4937
|
+
removed.length ? `removed: ${removed.join(', ')}` : 'no shims to remove',
|
|
4938
|
+
pathEntryRemovalMessage(pathEntry)
|
|
4939
|
+
].join('\n'));
|
|
4631
4940
|
return;
|
|
4632
4941
|
}
|
|
4633
4942
|
if (sub === 'status') {
|
|
4634
|
-
const
|
|
4635
|
-
printResult(ctx,
|
|
4636
|
-
|
|
4637
|
-
|
|
4943
|
+
const status = shimStatus();
|
|
4944
|
+
printResult(ctx, status, (r) => [
|
|
4945
|
+
`shim dir: ${r.shimDir}`,
|
|
4946
|
+
`claude: ${r.tools.claude.installed ? 'installed' : 'missing'} (${r.tools.claude.path})`,
|
|
4947
|
+
`codex: ${r.tools.codex.installed ? 'installed' : 'missing'} (${r.tools.codex.path})`,
|
|
4948
|
+
r.complete
|
|
4949
|
+
? 'status: complete'
|
|
4950
|
+
: r.installed
|
|
4951
|
+
? 'status: partial (run: hcc shim install)'
|
|
4952
|
+
: 'status: not installed (run: hcc shim install)'
|
|
4953
|
+
].join('\n'));
|
|
4638
4954
|
return;
|
|
4639
4955
|
}
|
|
4640
4956
|
throw new CliError('BAD_ARGS', `Unknown shim subcommand: ${sub}`);
|
|
@@ -4660,7 +4976,7 @@ async function cmdSetup(ctx, args) {
|
|
|
4660
4976
|
|
|
4661
4977
|
// 2. Install Claude Code + Codex hooks
|
|
4662
4978
|
const { installClaudeHooks, verifyClaudeHooks, installCodexHooks, verifyCodexHooks,
|
|
4663
|
-
installShims,
|
|
4979
|
+
installShims, installPathEntry } = await loadSetup();
|
|
4664
4980
|
const hccBin = commandPath();
|
|
4665
4981
|
|
|
4666
4982
|
if (!verifyClaudeHooks()) {
|
|
@@ -4678,20 +4994,23 @@ async function cmdSetup(ctx, args) {
|
|
|
4678
4994
|
}
|
|
4679
4995
|
|
|
4680
4996
|
// 3. Install shims
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
if (result.
|
|
4684
|
-
log(`✓ shims installed → ${result.
|
|
4997
|
+
const result = installShims(hccBin);
|
|
4998
|
+
if (result.installed.length) {
|
|
4999
|
+
if (result.changed.length) {
|
|
5000
|
+
log(`✓ shims installed → ${result.changed.join(', ')}`);
|
|
4685
5001
|
} else {
|
|
4686
|
-
log(
|
|
5002
|
+
log(`✓ shims already installed → ${result.installed.join(', ')}`);
|
|
4687
5003
|
}
|
|
5004
|
+
if (result.skipped.length) log(`⚠ shims skipped: ${result.skipped.join(', ')}`);
|
|
4688
5005
|
const { alreadyPresent, rcFile } = installPathEntry();
|
|
4689
5006
|
if (!alreadyPresent) {
|
|
4690
5007
|
log(`✓ PATH updated in ${rcFile}`);
|
|
4691
5008
|
log(` run: source ${rcFile} (or open a new terminal)`);
|
|
5009
|
+
} else {
|
|
5010
|
+
log(`✓ PATH entry already present in ${rcFile}`);
|
|
4692
5011
|
}
|
|
4693
5012
|
} else {
|
|
4694
|
-
log('
|
|
5013
|
+
log('⚠ shims: claude/codex not found on PATH — skipped');
|
|
4695
5014
|
}
|
|
4696
5015
|
|
|
4697
5016
|
log('\nDone. Run `hcc web` for the default coordinated terminal experience.');
|
|
@@ -4771,13 +5090,15 @@ async function cmdUninstall(ctx, args) {
|
|
|
4771
5090
|
lines.push('runtime not running');
|
|
4772
5091
|
}
|
|
4773
5092
|
|
|
4774
|
-
const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims } = await loadSetup();
|
|
5093
|
+
const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
|
|
4775
5094
|
const claude = uninstallClaudeHooks();
|
|
4776
5095
|
const codex = uninstallCodexHooks();
|
|
4777
5096
|
const shims = uninstallShims();
|
|
5097
|
+
const pathEntry = uninstallPathEntry();
|
|
4778
5098
|
lines.push(claude ? 'Claude Code hooks removed' : 'Claude Code hooks not found');
|
|
4779
5099
|
lines.push(codex ? 'Codex hooks removed' : 'Codex hooks not found');
|
|
4780
5100
|
lines.push(shims.length ? `shims removed: ${shims.join(', ')}` : 'shims not found');
|
|
5101
|
+
lines.push(pathEntryRemovalMessage(pathEntry));
|
|
4781
5102
|
|
|
4782
5103
|
let guidance = [];
|
|
4783
5104
|
let purged = false;
|
|
@@ -4795,7 +5116,7 @@ async function cmdUninstall(ctx, args) {
|
|
|
4795
5116
|
lines.push('project data kept; run hcc uninstall --purge --yes to remove .hello-cc and guidance blocks');
|
|
4796
5117
|
}
|
|
4797
5118
|
|
|
4798
|
-
printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, purge: purged, guidance }, () => lines.join('\n'));
|
|
5119
|
+
printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
|
|
4799
5120
|
}
|
|
4800
5121
|
|
|
4801
5122
|
// ─── hcc scan ────────────────────────────────────────────────────────────────
|
|
@@ -4808,7 +5129,7 @@ async function cmdScan(ctx, args) {
|
|
|
4808
5129
|
...scanClaudeSessions(),
|
|
4809
5130
|
...scanCodexSessions(),
|
|
4810
5131
|
...scanProcesses(),
|
|
4811
|
-
].filter((s) => s.hccRoot
|
|
5132
|
+
].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
|
|
4812
5133
|
|
|
4813
5134
|
// Deduplicate by peerId
|
|
4814
5135
|
const byId = new Map();
|