@indigoai-us/hq-cli 5.148.0 → 5.149.1
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 +24 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
- package/dist/command-catalog.generated.d.ts +129 -0
- package/dist/command-catalog.generated.js +168 -0
- package/dist/commands/lanes.js +506 -23
- package/dist/commands/mesh.js +82 -11
- package/dist/lib/lanes/index.d.ts +3 -2
- package/dist/lib/lanes/index.js +2 -1
- package/dist/lib/lanes/mesh-events.d.ts +8 -0
- package/dist/lib/lanes/mesh-events.js +83 -6
- package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
- package/dist/lib/lanes/outpost-dispatch.js +17 -0
- package/dist/lib/lanes/reconcile.js +39 -12
- package/dist/lib/lanes/session-relink.d.ts +25 -0
- package/dist/lib/lanes/session-relink.js +216 -0
- package/dist/lib/lanes/spawn.d.ts +1 -0
- package/dist/lib/lanes/spawn.js +48 -5
- package/dist/lib/lanes/store.js +109 -1
- package/dist/lib/lanes/stories.d.ts +17 -0
- package/dist/lib/lanes/stories.js +73 -0
- package/dist/lib/lanes/types.d.ts +20 -0
- package/dist/lib/lanes/types.js +9 -2
- package/dist/lib/lanes/watcher.js +69 -9
- package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/doctor.js +84 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
- package/dist/lib/mesh/live/daemon/index.js +2 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
- package/dist/lib/mesh/live/daemon/install.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +12 -1
- package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
- package/dist/lib/mesh/project-board.d.ts +15 -0
- package/dist/lib/mesh/project-board.js +84 -0
- package/dist/lib/work-context/prd-sync.d.ts +5 -1
- package/dist/lib/work-context/prd-sync.js +22 -1
- package/package.json +5 -3
- package/scripts/mesh-daemon-if-stale.mjs +16 -0
|
@@ -36,6 +36,7 @@ import * as path from "node:path";
|
|
|
36
36
|
import { LanesError } from "./errors.js";
|
|
37
37
|
import { raiseEnvelopeQuestions } from "./questions.js";
|
|
38
38
|
import { isTerminalLaneState, parseLaneEnvelope, } from "./types.js";
|
|
39
|
+
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
39
40
|
import { readLane, retryUpdateLane } from "./store.js";
|
|
40
41
|
import { readWorkerRunResult } from "./delivery.js";
|
|
41
42
|
import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
@@ -243,8 +244,15 @@ function runResultFields(hqRoot, lane) {
|
|
|
243
244
|
fields.provider_session_id_absent = undefined;
|
|
244
245
|
}
|
|
245
246
|
else {
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
// A resumed provider may exit before it emits a new result object. Keep
|
|
248
|
+
// the prior session id so the lane remains resumable; only describe an
|
|
249
|
+
// absent id when no earlier run recorded one.
|
|
250
|
+
if (lane.provider_session_id || lane.last_resume?.session_id) {
|
|
251
|
+
fields.provider_session_id_absent = undefined;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
fields.provider_session_id_absent = result.session_id_absent ?? "no_result_json";
|
|
255
|
+
}
|
|
248
256
|
}
|
|
249
257
|
return fields;
|
|
250
258
|
}
|
|
@@ -337,17 +345,36 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
337
345
|
if (opts.providerRejection) {
|
|
338
346
|
next.terminal_details = providerRejectionDetails(opts.providerRejection, current);
|
|
339
347
|
}
|
|
340
|
-
|
|
348
|
+
const envelope = opts.envelope;
|
|
349
|
+
if (envelope && opts.envelopeRef) {
|
|
341
350
|
next.last_envelope = {
|
|
342
351
|
ref: opts.envelopeRef,
|
|
343
|
-
decision:
|
|
352
|
+
decision: envelope.decision,
|
|
344
353
|
at,
|
|
345
354
|
};
|
|
346
|
-
next.story_status =
|
|
347
|
-
if (
|
|
348
|
-
next.story_complete =
|
|
349
|
-
next.story_complete_reason =
|
|
355
|
+
next.story_status = envelope.story_status;
|
|
356
|
+
if (envelope.story_complete !== undefined) {
|
|
357
|
+
next.story_complete = envelope.story_complete;
|
|
358
|
+
next.story_complete_reason = envelope.story_complete_reason;
|
|
350
359
|
}
|
|
360
|
+
const currentProject = current.project_id;
|
|
361
|
+
const currentStory = current.story_id;
|
|
362
|
+
const storyStatus = envelope.decision === "blocked"
|
|
363
|
+
? "blocked"
|
|
364
|
+
: envelope.decision === "done"
|
|
365
|
+
? "review"
|
|
366
|
+
: storyStatusForEnvelope(envelope.story_status);
|
|
367
|
+
const updatedStories = laneStories(current).map((story) => story.project_id === currentProject && story.story_id === currentStory
|
|
368
|
+
? {
|
|
369
|
+
...story,
|
|
370
|
+
status: storyStatus,
|
|
371
|
+
updated_at: at,
|
|
372
|
+
...(envelope.decision === "blocked"
|
|
373
|
+
? { blocked_reason: envelope.blocked_reason ?? "" }
|
|
374
|
+
: { blocked_reason: undefined }),
|
|
375
|
+
}
|
|
376
|
+
: story);
|
|
377
|
+
next.stories = updatedStories;
|
|
351
378
|
}
|
|
352
379
|
return next;
|
|
353
380
|
});
|
|
@@ -550,9 +577,42 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
550
577
|
const progressFiles = [...logFiles, ...activityFiles];
|
|
551
578
|
// Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
|
|
552
579
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
580
|
+
const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
|
|
581
|
+
const initialLane = readLaneSoft(hqRoot, laneId);
|
|
582
|
+
if (mesh && initialLane) {
|
|
583
|
+
// Explicit mesh attribution is the repair signal. Ordinary company lanes
|
|
584
|
+
// already receive their thread during create; legacy records without one
|
|
585
|
+
// stay quiet until an operator or the owning flow repairs them.
|
|
586
|
+
const company = initialLane.mesh_company;
|
|
587
|
+
if (company && !initialLane.thread_id) {
|
|
588
|
+
let timer;
|
|
589
|
+
try {
|
|
590
|
+
const threadId = await Promise.race([
|
|
591
|
+
mesh.ensureProjectThread(company, initialLane.project_id),
|
|
592
|
+
new Promise((_resolve, reject) => {
|
|
593
|
+
timer = setTimeout(() => reject(new Error(`mesh thread ensure timed out after ${meshRequestTimeoutMs}ms`)), meshRequestTimeoutMs);
|
|
594
|
+
}),
|
|
595
|
+
]);
|
|
596
|
+
retryUpdateLane(hqRoot, laneId, "spawn", (current) => current.thread_id ? current : { ...current, thread_id: threadId });
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
600
|
+
process.stderr.write(`hq lanes watcher: mesh thread ensure failed for ${laneId}: ${message}\n`);
|
|
601
|
+
writeWatcherDecision(outPath, {
|
|
602
|
+
event: "mesh_event_failed",
|
|
603
|
+
at: new Date(now()).toISOString(),
|
|
604
|
+
mesh_event: "ensure_project_thread",
|
|
605
|
+
error: message,
|
|
606
|
+
}, outMax);
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
if (timer)
|
|
610
|
+
clearTimeout(timer);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
553
614
|
const progressIntervalMs = deps.progressEventMinIntervalMs ?? readProgressIntervalMs(hqRoot, readLaneSoft(hqRoot, laneId));
|
|
554
615
|
const spawnRepair = deps.spawnRepair ?? spawnEnvelopeRepair;
|
|
555
|
-
const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
|
|
556
616
|
let lastSignalMs = now();
|
|
557
617
|
let lastSeenMtime = 0;
|
|
558
618
|
let lastAlive = null;
|
|
@@ -57,6 +57,15 @@ export interface DaemonDoctorReport {
|
|
|
57
57
|
deviceDefaultAuthorizationDenials: number;
|
|
58
58
|
/** True when the pack-era listen unit is still on disk. */
|
|
59
59
|
legacyListenerInstalled: boolean;
|
|
60
|
+
/** One line when the running daemon's recorded CLI is not this CLI. */
|
|
61
|
+
cliMismatchLine?: string;
|
|
62
|
+
/** Every `hq` executable found on PATH, with the package version behind it. */
|
|
63
|
+
hqOnPath: Array<{
|
|
64
|
+
path: string;
|
|
65
|
+
version: string | null;
|
|
66
|
+
}>;
|
|
67
|
+
/** True when PATH lists more than one hq. */
|
|
68
|
+
multipleHqOnPath: boolean;
|
|
60
69
|
}
|
|
61
70
|
export interface DaemonDoctorDeps {
|
|
62
71
|
home?: string;
|
|
@@ -70,7 +79,25 @@ export interface DaemonDoctorDeps {
|
|
|
70
79
|
/** Override the legacy-listen probe (tests). */
|
|
71
80
|
legacyListenerInstalled?: boolean;
|
|
72
81
|
platform?: LegacyListenPlatform;
|
|
82
|
+
/** This process's CLI version. Defaults to the package version. */
|
|
83
|
+
cliVersion?: string;
|
|
84
|
+
/** This process's CLI entry. Defaults to realpath of argv[1]. */
|
|
85
|
+
cliEntry?: string;
|
|
86
|
+
/** PATH used to list hq binaries. Defaults to the process PATH. */
|
|
87
|
+
pathEnv?: string;
|
|
73
88
|
}
|
|
89
|
+
/** Every hq named on PATH. Does not pick one; the caller prints all of them. */
|
|
90
|
+
export declare function listHqBinariesOnPath(env?: NodeJS.ProcessEnv): Array<{
|
|
91
|
+
path: string;
|
|
92
|
+
version: string | null;
|
|
93
|
+
}>;
|
|
94
|
+
export declare function formatCliMismatchLine(input: {
|
|
95
|
+
recordedVersion?: string | null;
|
|
96
|
+
recordedEntry?: string | null;
|
|
97
|
+
currentVersion: string;
|
|
98
|
+
currentEntry: string;
|
|
99
|
+
}): string | undefined;
|
|
100
|
+
export declare function formatCliIdentityLines(report: Pick<DaemonDoctorReport, "cliMismatchLine" | "hqOnPath" | "multipleHqOnPath">): string[];
|
|
74
101
|
export declare function collectDaemonDoctor(deps?: DaemonDoctorDeps): DaemonDoctorReport;
|
|
75
102
|
export declare function formatDaemonDoctor(report: DaemonDoctorReport): string[];
|
|
76
103
|
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -19,6 +19,9 @@ import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompa
|
|
|
19
19
|
import { readDaemonConfig } from "./config.js";
|
|
20
20
|
import { pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
21
21
|
import { readDaemonState } from "./state.js";
|
|
22
|
+
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
23
|
+
import { resolveRunningCliEntry } from "./install.js";
|
|
24
|
+
import { readInstalledCliVersion } from "./version-drift.js";
|
|
22
25
|
import { LEGACY_LISTENER_DOCTOR_LINE, legacyListenInstalled, } from "./legacy-listen.js";
|
|
23
26
|
export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
|
|
24
27
|
function oldestJsonlAgeMs(filePath, nowMs) {
|
|
@@ -55,6 +58,65 @@ function oldestJsonlAgeMs(filePath, nowMs) {
|
|
|
55
58
|
return undefined;
|
|
56
59
|
}
|
|
57
60
|
}
|
|
61
|
+
function samePath(a, b) {
|
|
62
|
+
const left = tryRealpath(a);
|
|
63
|
+
const right = tryRealpath(b);
|
|
64
|
+
return left === right;
|
|
65
|
+
}
|
|
66
|
+
function tryRealpath(value) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.realpathSync(value);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return path.resolve(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Every hq named on PATH. Does not pick one; the caller prints all of them. */
|
|
75
|
+
export function listHqBinariesOnPath(env = process.env) {
|
|
76
|
+
const found = [];
|
|
77
|
+
const pathEnv = env.PATH ?? "";
|
|
78
|
+
const names = process.platform === "win32" ? ["hq.cmd", "hq.exe", "hq"] : ["hq"];
|
|
79
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
80
|
+
if (!dir)
|
|
81
|
+
continue;
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const candidate = path.join(dir, name);
|
|
84
|
+
if (!fs.existsSync(candidate))
|
|
85
|
+
continue;
|
|
86
|
+
found.push({
|
|
87
|
+
path: tryRealpath(candidate),
|
|
88
|
+
version: readInstalledCliVersion(candidate),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
export function formatCliMismatchLine(input) {
|
|
95
|
+
const recordedVersion = input.recordedVersion?.trim() || "";
|
|
96
|
+
const recordedEntry = input.recordedEntry?.trim() || "";
|
|
97
|
+
const versionMatches = recordedVersion !== "" && recordedVersion === input.currentVersion;
|
|
98
|
+
const entryMatches = recordedEntry !== "" &&
|
|
99
|
+
input.currentEntry !== "" &&
|
|
100
|
+
samePath(recordedEntry, input.currentEntry);
|
|
101
|
+
if (versionMatches && entryMatches)
|
|
102
|
+
return undefined;
|
|
103
|
+
const recordedLabel = recordedVersion || "(none)";
|
|
104
|
+
const recordedAt = recordedEntry || "(none)";
|
|
105
|
+
const currentAt = input.currentEntry || "(none)";
|
|
106
|
+
return (`daemon hq ${recordedLabel} at ${recordedAt} does not match this CLI ` +
|
|
107
|
+
`${input.currentVersion} at ${currentAt}. Run hq mesh daemon install to repoint it.`);
|
|
108
|
+
}
|
|
109
|
+
export function formatCliIdentityLines(report) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
if (report.cliMismatchLine)
|
|
112
|
+
lines.push(report.cliMismatchLine);
|
|
113
|
+
for (const hit of report.hqOnPath) {
|
|
114
|
+
lines.push(`hq on PATH: ${hit.path} (${hit.version ?? "unknown"})`);
|
|
115
|
+
}
|
|
116
|
+
if (report.multipleHqOnPath)
|
|
117
|
+
lines.push("more than one hq is on PATH");
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
58
120
|
function countDeviceDefaultAttribution(root) {
|
|
59
121
|
try {
|
|
60
122
|
return fs.readdirSync(path.join(root, "sessions")).filter((entry) => {
|
|
@@ -93,6 +155,24 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
93
155
|
const record = readPidLock(lockPath);
|
|
94
156
|
const running = lock === "held";
|
|
95
157
|
const state = readDaemonState(dir);
|
|
158
|
+
let currentEntry = deps.cliEntry ?? "";
|
|
159
|
+
if (!deps.cliEntry) {
|
|
160
|
+
try {
|
|
161
|
+
currentEntry = resolveRunningCliEntry();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
currentEntry = "";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const currentVersion = deps.cliVersion ?? CLI_VERSION;
|
|
168
|
+
const pathEnv = deps.pathEnv ?? env.PATH ?? "";
|
|
169
|
+
const hqOnPath = listHqBinariesOnPath({ ...env, PATH: pathEnv });
|
|
170
|
+
const cliMismatchLine = formatCliMismatchLine({
|
|
171
|
+
recordedVersion: state?.cliVersion,
|
|
172
|
+
recordedEntry: state?.cliEntry,
|
|
173
|
+
currentVersion,
|
|
174
|
+
currentEntry,
|
|
175
|
+
});
|
|
96
176
|
const spoolPath = workMeshSpoolPath(meshRoot);
|
|
97
177
|
const heldPath = workMeshHeldPath(meshRoot);
|
|
98
178
|
const deadPath = workMeshDeadLetterPath(meshRoot);
|
|
@@ -203,6 +283,9 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
203
283
|
: undefined,
|
|
204
284
|
deviceDefaultAuthorizationDenials,
|
|
205
285
|
legacyListenerInstalled: legacyListener,
|
|
286
|
+
cliMismatchLine,
|
|
287
|
+
hqOnPath,
|
|
288
|
+
multipleHqOnPath: hqOnPath.length > 1,
|
|
206
289
|
};
|
|
207
290
|
}
|
|
208
291
|
export function formatDaemonDoctor(report) {
|
|
@@ -253,6 +336,7 @@ export function formatDaemonDoctor(report) {
|
|
|
253
336
|
else {
|
|
254
337
|
lines.push(`health: ok`);
|
|
255
338
|
}
|
|
339
|
+
lines.push(...formatCliIdentityLines(report));
|
|
256
340
|
return lines;
|
|
257
341
|
}
|
|
258
342
|
//# sourceMappingURL=doctor.js.map
|
|
@@ -12,12 +12,12 @@ export type { DaemonStateFile, PresenceRefusalState } from "./state.js";
|
|
|
12
12
|
export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
|
|
13
13
|
export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
|
|
14
14
|
export type { BoardReader, BoardRefreshDeps, BoardStorySnapshot } from "./board-refresh.js";
|
|
15
|
-
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
|
|
16
|
-
export type { DaemonInstallPlatform, InstallPaths, InstallResult, } from "./install.js";
|
|
15
|
+
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonIfStale, installDaemonService, isNewerCliVersion, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveAbsoluteNodeBinary, resolveHqBinary, resolveNodeBinary, resolveRunningCliEntry, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
|
|
16
|
+
export type { DaemonInstallPlatform, InstallIfStaleResult, InstallPaths, InstallResult, } from "./install.js";
|
|
17
17
|
export { daemonConfigPath, findAncestorHqRoot, isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, resolveInstallHqRoot, verifyHqRoot, } from "./config.js";
|
|
18
18
|
export type { InstallHqRootResolution } from "./config.js";
|
|
19
19
|
export type { DaemonConfig } from "./config.js";
|
|
20
|
-
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
|
|
20
|
+
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatCliIdentityLines, formatCliMismatchLine, formatDaemonDoctor, listHqBinariesOnPath, } from "./doctor.js";
|
|
21
21
|
export type { DaemonDoctorDeps, DaemonDoctorReport } from "./doctor.js";
|
|
22
22
|
export { scopeBundleToAgentIdentity, } from "./credentials.js";
|
|
23
23
|
export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
@@ -6,9 +6,9 @@ export { CLOSE_LOG_ALWAYS_COUNT, CLOSE_LOG_INTERVAL_MS, CONNECT_STABLE_GRACE_MS,
|
|
|
6
6
|
export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
|
|
7
7
|
export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
|
|
8
8
|
export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
|
|
9
|
-
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
|
|
9
|
+
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonIfStale, installDaemonService, isNewerCliVersion, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveAbsoluteNodeBinary, resolveHqBinary, resolveNodeBinary, resolveRunningCliEntry, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
|
|
10
10
|
export { daemonConfigPath, findAncestorHqRoot, isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, resolveInstallHqRoot, verifyHqRoot, } from "./config.js";
|
|
11
|
-
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
|
|
11
|
+
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatCliIdentityLines, formatCliMismatchLine, formatDaemonDoctor, listHqBinariesOnPath, } from "./doctor.js";
|
|
12
12
|
export { scopeBundleToAgentIdentity, } from "./credentials.js";
|
|
13
13
|
export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
14
14
|
export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Tests must not actually install services — use a temp HOME/prefix.
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
|
+
import { type RunCommand } from "../../../service-manager/launchd.js";
|
|
15
16
|
export type DaemonInstallPlatform = "darwin" | "linux" | "win32" | "other";
|
|
16
17
|
/** Result of one schtasks.exe invocation (tests inject; never the real binary). */
|
|
17
18
|
export interface SchtasksResult {
|
|
@@ -121,8 +122,47 @@ export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstal
|
|
|
121
122
|
* binary when both exist.
|
|
122
123
|
*/
|
|
123
124
|
export declare function preferStableHqBinary(entry: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Absolute path of the CLI entry that is executing this process.
|
|
127
|
+
* realpath of argv[1] only — never a PATH lookup of `hq`.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveRunningCliEntry(argv?: string[]): string;
|
|
130
|
+
/** Absolute node binary (realpath of process.execPath). No PATH lookup. */
|
|
131
|
+
export declare function resolveAbsoluteNodeBinary(execPath?: string): string;
|
|
124
132
|
/** Resolve the hq binary the current shell uses (argv[1] or PATH). */
|
|
125
133
|
export declare function resolveHqBinary(argv?: string[], env?: NodeJS.ProcessEnv): string;
|
|
134
|
+
/** True when this CLI is newer than the version the daemon recorded, or nothing was recorded. */
|
|
135
|
+
export declare function isNewerCliVersion(current: string, recorded: string | null | undefined): boolean;
|
|
136
|
+
export interface InstallIfStaleResult {
|
|
137
|
+
reinstalled: boolean;
|
|
138
|
+
message: string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reinstall and restart the user daemon only when `currentVersion` is newer
|
|
142
|
+
* than daemon/state.json `cliVersion`. A missing record counts as stale.
|
|
143
|
+
* `run` is the launchctl/systemctl seam — tests pass a stub.
|
|
144
|
+
*/
|
|
145
|
+
export declare function installDaemonIfStale(opts: {
|
|
146
|
+
currentVersion: string;
|
|
147
|
+
platform?: DaemonInstallPlatform;
|
|
148
|
+
paths: InstallPaths;
|
|
149
|
+
systemUser?: string;
|
|
150
|
+
run?: RunCommand;
|
|
151
|
+
uid?: number;
|
|
152
|
+
writeFileSync?: typeof fs.writeFileSync;
|
|
153
|
+
mkdirSync?: typeof fs.mkdirSync;
|
|
154
|
+
reloadSystemd?: () => {
|
|
155
|
+
status: number | null;
|
|
156
|
+
};
|
|
157
|
+
schtasks?: SchtasksRunner;
|
|
158
|
+
kill?: (pid: number) => void;
|
|
159
|
+
runCommand?: (cmd: string, args: string[]) => {
|
|
160
|
+
status: number | null;
|
|
161
|
+
stdout: string;
|
|
162
|
+
stderr: string;
|
|
163
|
+
};
|
|
164
|
+
windowsLegacyTaskPresent?: boolean;
|
|
165
|
+
}): InstallIfStaleResult;
|
|
126
166
|
export declare function resolveNodeBinary(execPath?: string, env?: NodeJS.ProcessEnv): string;
|
|
127
167
|
export declare function buildInstallPaths(opts: {
|
|
128
168
|
home?: string;
|
|
@@ -15,11 +15,15 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import semver from "semver";
|
|
18
19
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
19
20
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
21
|
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
21
22
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
23
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
24
|
+
import { readDaemonState } from "./state.js";
|
|
25
|
+
import { activateLaunchd } from "../../../service-manager/launchd.js";
|
|
26
|
+
import { activateSystemd } from "../../../service-manager/systemd.js";
|
|
23
27
|
/**
|
|
24
28
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
25
29
|
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
@@ -212,6 +216,35 @@ export function preferStableHqBinary(entry) {
|
|
|
212
216
|
return path.resolve(candidate);
|
|
213
217
|
return entry;
|
|
214
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Absolute path of the CLI entry that is executing this process.
|
|
221
|
+
* realpath of argv[1] only — never a PATH lookup of `hq`.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveRunningCliEntry(argv = process.argv) {
|
|
224
|
+
const entry = argv[1];
|
|
225
|
+
if (!entry) {
|
|
226
|
+
throw new Error("Cannot resolve hq binary: process.argv[1] is empty");
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return fs.realpathSync(entry);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error(`Cannot resolve hq binary: ${entry} is not a real path`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Absolute node binary (realpath of process.execPath). No PATH lookup. */
|
|
236
|
+
export function resolveAbsoluteNodeBinary(execPath = process.execPath) {
|
|
237
|
+
if (!execPath)
|
|
238
|
+
throw new Error("Cannot resolve node binary: execPath is empty");
|
|
239
|
+
try {
|
|
240
|
+
return fs.realpathSync(execPath);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
if (path.isAbsolute(execPath))
|
|
244
|
+
return execPath;
|
|
245
|
+
throw new Error(`Cannot resolve node binary: ${execPath} is not a real path`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
215
248
|
/** Resolve the hq binary the current shell uses (argv[1] or PATH). */
|
|
216
249
|
export function resolveHqBinary(argv = process.argv, env = process.env) {
|
|
217
250
|
const entry = argv[1];
|
|
@@ -230,6 +263,61 @@ export function resolveHqBinary(argv = process.argv, env = process.env) {
|
|
|
230
263
|
}
|
|
231
264
|
throw new Error("Cannot resolve hq binary for daemon install");
|
|
232
265
|
}
|
|
266
|
+
/** True when this CLI is newer than the version the daemon recorded, or nothing was recorded. */
|
|
267
|
+
export function isNewerCliVersion(current, recorded) {
|
|
268
|
+
const recordedVersion = recorded?.trim() ?? "";
|
|
269
|
+
if (!recordedVersion)
|
|
270
|
+
return true;
|
|
271
|
+
if (semver.valid(current) && semver.valid(recordedVersion)) {
|
|
272
|
+
return semver.gt(current, recordedVersion);
|
|
273
|
+
}
|
|
274
|
+
return current !== recordedVersion;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Reinstall and restart the user daemon only when `currentVersion` is newer
|
|
278
|
+
* than daemon/state.json `cliVersion`. A missing record counts as stale.
|
|
279
|
+
* `run` is the launchctl/systemctl seam — tests pass a stub.
|
|
280
|
+
*/
|
|
281
|
+
export function installDaemonIfStale(opts) {
|
|
282
|
+
const recorded = readDaemonState(opts.paths.daemonDir)?.cliVersion;
|
|
283
|
+
if (!isNewerCliVersion(opts.currentVersion, recorded)) {
|
|
284
|
+
return {
|
|
285
|
+
reinstalled: false,
|
|
286
|
+
message: `daemon already records hq ${recorded}; not reinstalling`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const run = opts.run ?? ((command, args) => {
|
|
290
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
291
|
+
return {
|
|
292
|
+
status: result.status,
|
|
293
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
294
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
const platform = opts.platform ?? detectPlatform();
|
|
298
|
+
const installed = installDaemonService({
|
|
299
|
+
platform,
|
|
300
|
+
paths: opts.paths,
|
|
301
|
+
systemUser: opts.systemUser,
|
|
302
|
+
writeFileSync: opts.writeFileSync,
|
|
303
|
+
mkdirSync: opts.mkdirSync,
|
|
304
|
+
reloadSystemd: opts.reloadSystemd,
|
|
305
|
+
schtasks: opts.schtasks,
|
|
306
|
+
kill: opts.kill,
|
|
307
|
+
runCommand: opts.runCommand,
|
|
308
|
+
windowsLegacyTaskPresent: opts.windowsLegacyTaskPresent,
|
|
309
|
+
});
|
|
310
|
+
if (platform === "darwin") {
|
|
311
|
+
activateLaunchd(opts.paths.plistPath, run, opts.uid);
|
|
312
|
+
}
|
|
313
|
+
else if (platform === "linux" && !opts.systemUser && !opts.paths.systemUser) {
|
|
314
|
+
activateSystemd(SYSTEMD_UNIT_NAME, run);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
reinstalled: true,
|
|
318
|
+
message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
233
321
|
export function resolveNodeBinary(execPath = process.execPath, env = process.env) {
|
|
234
322
|
if (execPath && fs.existsSync(execPath))
|
|
235
323
|
return execPath;
|
|
@@ -103,6 +103,8 @@ export interface DaemonRunDeps {
|
|
|
103
103
|
runForMs?: number;
|
|
104
104
|
/** CLI version loaded by this daemon. Defaults to this package's version. */
|
|
105
105
|
daemonVersion?: string;
|
|
106
|
+
/** realpath of the CLI entry. Defaults to process.argv[1]. */
|
|
107
|
+
cliEntry?: string;
|
|
106
108
|
/**
|
|
107
109
|
* Subscribe to the personal work topic and write live-cache.json.
|
|
108
110
|
* The `hq mesh daemon run` command turns this on. Tests leave it off
|
|
@@ -36,6 +36,7 @@ import { workMeshHeldPath } from "../paths.js";
|
|
|
36
36
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
37
37
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
38
38
|
import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
|
|
39
|
+
import { resolveRunningCliEntry } from "./install.js";
|
|
39
40
|
import { readDaemonConfig } from "./config.js";
|
|
40
41
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
41
42
|
import { startWorkCacheSubscriber } from "./work-subscribe.js";
|
|
@@ -140,7 +141,17 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
140
141
|
}
|
|
141
142
|
writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
|
|
142
143
|
log(dir, `daemon started pid=${lockDeps.pid} mode=${mode}`);
|
|
143
|
-
|
|
144
|
+
let cliEntry = deps.cliEntry ?? "";
|
|
145
|
+
if (!cliEntry) {
|
|
146
|
+
try {
|
|
147
|
+
cliEntry = resolveRunningCliEntry();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
cliEntry = process.argv[1] ?? "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
log(dir, `daemon version=${daemonVersion} entry=${cliEntry}`);
|
|
154
|
+
patchDaemonState(dir, { emitMode: mode, cliVersion: daemonVersion, cliEntry }, now);
|
|
144
155
|
// Opt into machine-credential minting when the box has creds (systemd exports
|
|
145
156
|
// HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
|
|
146
157
|
// through to the person login cache inside ensureCognitoToken.
|
|
@@ -31,6 +31,10 @@ export interface DaemonStateFile {
|
|
|
31
31
|
deviceDefaultAuthorizationDenials?: number;
|
|
32
32
|
/** Transient device-default membership refresh failures; the next cycle retries. */
|
|
33
33
|
deviceDefaultMembershipLookupFailures?: number;
|
|
34
|
+
/** CLI version this daemon process was started with. */
|
|
35
|
+
cliVersion?: string;
|
|
36
|
+
/** realpath of the CLI entry this daemon process was started with. */
|
|
37
|
+
cliEntry?: string;
|
|
34
38
|
updatedAt: string;
|
|
35
39
|
}
|
|
36
40
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Add Board stories that a prd-sync emit is about to name, when they are missing.
|
|
4
|
+
* A missing view uses ensureProjectBoard. An existing view is merged so live
|
|
5
|
+
* status stays. Callers that must not block the emit should catch failures.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureMissingBoardStories(input: {
|
|
8
|
+
token: string;
|
|
9
|
+
company: MeshCompany;
|
|
10
|
+
projectId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
stories: MeshProjectStoryInput[];
|
|
14
|
+
repos?: MeshProjectRepo[];
|
|
15
|
+
requiredIds: readonly string[];
|
|
16
|
+
}): Promise<void>;
|
|
2
17
|
/**
|
|
3
18
|
* POST /v1/work-mesh/projects/{id}/register always binds the server-derived
|
|
4
19
|
* proj- thread (projectGenesisThreadId). It does not accept a client threadId.
|
|
@@ -1,4 +1,88 @@
|
|
|
1
1
|
import { appendThreadEvent, ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
|
|
2
|
+
function httpStatus(err) {
|
|
3
|
+
if (err && typeof err === "object" && "status" in err && typeof err.status === "number") {
|
|
4
|
+
return err.status;
|
|
5
|
+
}
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Same merge as `hq mesh project ensure`: add missing stories, keep live status. */
|
|
9
|
+
function mergeProjectStories(current, planned) {
|
|
10
|
+
const currentById = new Map(current.map((story) => [story.id, story]));
|
|
11
|
+
const plannedIds = new Set(planned.map((story) => story.id));
|
|
12
|
+
const merged = planned.map((story) => {
|
|
13
|
+
const live = currentById.get(story.id);
|
|
14
|
+
if (!live)
|
|
15
|
+
return story;
|
|
16
|
+
const status = live.status || story.status;
|
|
17
|
+
return {
|
|
18
|
+
...story,
|
|
19
|
+
...(status ? { status } : {}),
|
|
20
|
+
passes: status === "done" ? true : typeof live.passes === "boolean" ? live.passes : story.passes,
|
|
21
|
+
...(live.assigneeUid ? { assigneeUid: live.assigneeUid } : {}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return [...merged, ...current.filter((story) => !plannedIds.has(story.id))];
|
|
25
|
+
}
|
|
26
|
+
function mergeProjectRepos(current, planned) {
|
|
27
|
+
const plannedPaths = new Set(planned.map((repo) => repo.path));
|
|
28
|
+
return [...planned, ...current.filter((repo) => !plannedPaths.has(repo.path))];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Add Board stories that a prd-sync emit is about to name, when they are missing.
|
|
32
|
+
* A missing view uses ensureProjectBoard. An existing view is merged so live
|
|
33
|
+
* status stays. Callers that must not block the emit should catch failures.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureMissingBoardStories(input) {
|
|
36
|
+
const required = new Set(input.requiredIds);
|
|
37
|
+
if (required.size === 0)
|
|
38
|
+
return;
|
|
39
|
+
let current;
|
|
40
|
+
try {
|
|
41
|
+
current = await getProjectView(input.token, input.company.companyUid, input.projectId);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (httpStatus(err) !== 404)
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!current) {
|
|
48
|
+
await ensureProjectBoard({
|
|
49
|
+
token: input.token,
|
|
50
|
+
company: input.company,
|
|
51
|
+
projectId: input.projectId,
|
|
52
|
+
name: input.name || input.projectId,
|
|
53
|
+
description: input.description ?? "",
|
|
54
|
+
stories: input.stories,
|
|
55
|
+
repos: input.repos ?? [],
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if ([...required].every((id) => current.stories.some((story) => story.id === id)))
|
|
60
|
+
return;
|
|
61
|
+
const genesis = await ensureProjectThread(input.token, input.company, input.projectId, {
|
|
62
|
+
summary: input.name || input.projectId,
|
|
63
|
+
});
|
|
64
|
+
if (!genesis.threadId)
|
|
65
|
+
throw new Error("Work mesh did not return a threadId.");
|
|
66
|
+
const stories = mergeProjectStories(current.stories, input.stories);
|
|
67
|
+
const repos = mergeProjectRepos(current.repos, input.repos ?? []);
|
|
68
|
+
const name = input.name || current.name;
|
|
69
|
+
const description = input.description !== undefined ? input.description : current.description;
|
|
70
|
+
await putProjectView(input.token, input.company.companyUid, input.projectId, {
|
|
71
|
+
name,
|
|
72
|
+
description,
|
|
73
|
+
stories,
|
|
74
|
+
repos,
|
|
75
|
+
...(current.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
76
|
+
});
|
|
77
|
+
const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
|
|
78
|
+
await closeSupersededProjectThread({
|
|
79
|
+
token: input.token,
|
|
80
|
+
companyUid: input.company.companyUid,
|
|
81
|
+
openedThreadId: genesis.threadId,
|
|
82
|
+
canonicalThreadId: registration.threadId,
|
|
83
|
+
created: genesis.created,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
2
86
|
/**
|
|
3
87
|
* POST /v1/work-mesh/projects/{id}/register always binds the server-derived
|
|
4
88
|
* proj- thread (projectGenesisThreadId). It does not accept a client threadId.
|
|
@@ -24,7 +24,11 @@ export interface PrdSyncPlan {
|
|
|
24
24
|
/** Story most recently moved to in_progress, if any. */
|
|
25
25
|
taskId?: string;
|
|
26
26
|
seqStart: number;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Write the snapshot. With no argument, every planned story is recorded.
|
|
29
|
+
* With ids, only those stories advance; the rest stay changed for a retry.
|
|
30
|
+
*/
|
|
31
|
+
commit: (acceptedTaskIds?: readonly string[]) => void;
|
|
28
32
|
}
|
|
29
33
|
export declare function planPrdSync(input: {
|
|
30
34
|
sessionId: string;
|