@indigoai-us/hq-cli 5.345.54 → 5.345.56

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +143 -2
  6. package/dist/command-catalog.generated.js +175 -2
  7. package/dist/command-registration-plan.d.ts +6 -1
  8. package/dist/command-registration-plan.js +2 -1
  9. package/dist/commands/cloud.d.ts +7 -0
  10. package/dist/commands/cloud.js +92 -16
  11. package/dist/commands/core-delegate-w1b.js +18 -5
  12. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  13. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  14. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  15. package/dist/commands/daemon.js +16 -0
  16. package/dist/commands/doctor.d.ts +1 -0
  17. package/dist/commands/doctor.js +15 -2
  18. package/dist/commands/import-scan.d.ts +24 -0
  19. package/dist/commands/import-scan.js +75 -0
  20. package/dist/commands/lanes-checks.d.ts +3 -0
  21. package/dist/commands/lanes-checks.js +34 -0
  22. package/dist/commands/lanes-session-start.d.ts +17 -0
  23. package/dist/commands/lanes-session-start.js +195 -0
  24. package/dist/commands/lanes-shared.d.ts +2 -1
  25. package/dist/commands/lanes-watch.d.ts +2 -0
  26. package/dist/commands/lanes-watch.js +2 -1
  27. package/dist/commands/lanes.d.ts +3 -1
  28. package/dist/commands/lanes.js +121 -18
  29. package/dist/commands/members.d.ts +4 -0
  30. package/dist/commands/members.js +23 -2
  31. package/dist/lanes-drain-hook.js +6 -2
  32. package/dist/lib/daemon/conflict-notices.d.ts +4 -0
  33. package/dist/lib/daemon/conflict-notices.js +80 -0
  34. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  35. package/dist/lib/doctor/checks/sync-health.js +47 -23
  36. package/dist/lib/import-scan/events.d.ts +135 -0
  37. package/dist/lib/import-scan/events.js +71 -0
  38. package/dist/lib/import-scan/run.d.ts +84 -0
  39. package/dist/lib/import-scan/run.js +308 -0
  40. package/dist/lib/import-scan/scan-event.schema.json +127 -0
  41. package/dist/lib/import-scan/validate.d.ts +31 -0
  42. package/dist/lib/import-scan/validate.js +111 -0
  43. package/dist/lib/lanes/dropbox.d.ts +4 -2
  44. package/dist/lib/lanes/dropbox.js +54 -9
  45. package/dist/lib/lanes/flag-keys.d.ts +10 -0
  46. package/dist/lib/lanes/flag-keys.js +10 -0
  47. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  48. package/dist/lib/lanes/hooks-install.js +70 -50
  49. package/dist/lib/lanes/monitor.js +1 -1
  50. package/dist/lib/lanes/reaper.d.ts +58 -0
  51. package/dist/lib/lanes/reaper.js +403 -0
  52. package/dist/lib/lanes/registry-flag.d.ts +2 -0
  53. package/dist/lib/lanes/registry-flag.js +1 -1
  54. package/dist/lib/lanes/spawn.d.ts +2 -0
  55. package/dist/lib/lanes/spawn.js +32 -2
  56. package/dist/lib/lanes/status-fields.d.ts +12 -0
  57. package/dist/lib/lanes/status-fields.js +189 -0
  58. package/dist/lib/lanes/types.js +1 -1
  59. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  60. package/dist/lib/lanes/worker-continuity.js +81 -0
  61. package/dist/lib/lanes/worker-process.d.ts +4 -0
  62. package/dist/lib/lanes/worker-process.js +77 -0
  63. package/dist/main.js +9 -3
  64. package/dist/utils/cli-telemetry.d.ts +4 -1
  65. package/dist/utils/cli-telemetry.js +8 -0
  66. package/dist/utils/jq-windows-compat.d.ts +13 -0
  67. package/dist/utils/jq-windows-compat.js +48 -0
  68. package/dist/utils/run-bundled-script.d.ts +3 -0
  69. package/dist/utils/run-bundled-script.js +9 -80
  70. package/dist/utils/windows-bash.d.ts +5 -0
  71. package/dist/utils/windows-bash.js +75 -0
  72. package/package.json +1 -1
@@ -0,0 +1,189 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { assertNoSecretText } from "./text-scan.js";
4
+ import { compareEnvelopeNames } from "./envelope-sequence.js";
5
+ import { isTerminalLaneState } from "./types.js";
6
+ const OUTPUT_TAIL_BYTES = 64 * 1024;
7
+ const INBOX_TAIL_BYTES = 32 * 1024;
8
+ const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
9
+ function readTail(filePath, maxBytes) {
10
+ let fd;
11
+ try {
12
+ fd = fs.openSync(filePath, "r");
13
+ const stat = fs.fstatSync(fd);
14
+ const size = stat.size;
15
+ const start = Math.max(0, size - maxBytes);
16
+ const buffer = Buffer.alloc(size - start);
17
+ fs.readSync(fd, buffer, 0, buffer.length, start);
18
+ return { text: buffer.toString("utf8"), size, mtimeMs: stat.mtimeMs };
19
+ }
20
+ catch (error) {
21
+ if (error.code !== "ENOENT") {
22
+ process.stderr.write(`hq lanes: could not read status tail ${filePath}: ${error instanceof Error ? error.message : String(error)}\n`);
23
+ }
24
+ return null;
25
+ }
26
+ finally {
27
+ if (fd !== undefined)
28
+ fs.closeSync(fd);
29
+ }
30
+ }
31
+ function candidateLines(value, depth = 0) {
32
+ if (depth > 4 || value === null || value === undefined)
33
+ return [];
34
+ if (typeof value === "string")
35
+ return value.split(/\r?\n/);
36
+ if (Array.isArray(value))
37
+ return value.flatMap((item) => candidateLines(item, depth + 1));
38
+ if (typeof value !== "object")
39
+ return [];
40
+ const record = value;
41
+ const direct = ["text", "content", "message", "last_assistant_message", "lastAssistantMessage"]
42
+ .flatMap((key) => candidateLines(record[key], depth + 1));
43
+ return direct.length > 0 ? direct : ["item", "message", "response", "result"]
44
+ .flatMap((key) => candidateLines(record[key], depth + 1));
45
+ }
46
+ function meaningfulLines(text) {
47
+ const lines = [];
48
+ for (const raw of text.split(/\r?\n/)) {
49
+ // eslint-disable-next-line no-control-regex
50
+ const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim();
51
+ if (!line || line.startsWith("{") || line.startsWith("[") || line.includes("cache_read_input_tokens"))
52
+ continue;
53
+ lines.push(line);
54
+ }
55
+ return lines;
56
+ }
57
+ function linesFromOutput(text) {
58
+ const output = [];
59
+ for (const raw of text.split(/\r?\n/)) {
60
+ if (!raw.trim())
61
+ continue;
62
+ try {
63
+ const parsed = JSON.parse(raw);
64
+ output.push(...meaningfulLines(candidateLines(parsed).join("\n")));
65
+ }
66
+ catch {
67
+ output.push(...meaningfulLines(raw));
68
+ }
69
+ }
70
+ return output;
71
+ }
72
+ function safeLastLine(lines) {
73
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
74
+ const line = lines[i].trim();
75
+ if (!line)
76
+ continue;
77
+ const clipped = line.length > 160 ? `${line.slice(0, 157)}…` : line;
78
+ try {
79
+ // Same secret detector used by the lane question flow. Drop unsafe output.
80
+ assertNoSecretText("last_line", clipped);
81
+ return clipped;
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ function lastPr(text) {
90
+ const matches = [...text.matchAll(PR_URL)];
91
+ return matches.length ? matches[matches.length - 1][0] : null;
92
+ }
93
+ function terminalExit(lane, runDir) {
94
+ if (!isTerminalLaneState(lane.state))
95
+ return null;
96
+ const exitRecord = readTail(path.join(runDir, "logs", "worker-exit.json"), 4 * 1024);
97
+ if (exitRecord !== null) {
98
+ try {
99
+ const parsed = JSON.parse(exitRecord.text);
100
+ if (typeof parsed.exit_code === "number" && Number.isInteger(parsed.exit_code))
101
+ return parsed.exit_code;
102
+ }
103
+ catch {
104
+ process.stderr.write(`hq lanes: worker exit record is invalid for ${lane.lane_id}\n`);
105
+ }
106
+ }
107
+ const row = lane;
108
+ const candidates = [row.worker_exit_code, row.exit_code, row.terminal_details?.exit_code];
109
+ return candidates.find((value) => Number.isInteger(value)) ?? null;
110
+ }
111
+ function startTimeMs(lane, runDir) {
112
+ const row = lane;
113
+ for (const value of [row.worker_started_at, row.started_at]) {
114
+ const parsed = typeof value === "number" ? value * 1000 : typeof value === "string" ? Date.parse(value) : NaN;
115
+ if (Number.isFinite(parsed) && parsed > 0)
116
+ return parsed;
117
+ }
118
+ try {
119
+ return fs.statSync(path.join(runDir, "worker.pid")).mtimeMs;
120
+ }
121
+ catch (error) {
122
+ if (error.code !== "ENOENT") {
123
+ process.stderr.write(`hq lanes: could not read worker start time ${runDir}: ${error instanceof Error ? error.message : String(error)}\n`);
124
+ }
125
+ return null;
126
+ }
127
+ }
128
+ /** Bounded, local-only status data. Remote-owned lanes report null for file-backed fields. */
129
+ export function readLaneStatusRowFields(hqRoot, lane, nowMs = Date.now(), local = true) {
130
+ if (!local)
131
+ return { last_line: null, pr: null, inbox_pending: null, elapsed_s: null, exit: null };
132
+ const runDir = path.join(hqRoot, "workspace", "lanes-runs", lane.lane_id);
133
+ const logsDir = path.join(runDir, "logs");
134
+ const outTail = readTail(path.join(logsDir, "worker.out"), OUTPUT_TAIL_BYTES);
135
+ const messageTail = readTail(path.join(logsDir, "last-message.txt"), OUTPUT_TAIL_BYTES);
136
+ const outputTails = [outTail, messageTail]
137
+ .filter((value) => value !== null)
138
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
139
+ const lines = outputTails.flatMap(({ text }) => linesFromOutput(text));
140
+ let envelopeTail = null;
141
+ try {
142
+ const names = fs.readdirSync(path.join(runDir, "envelopes"))
143
+ .filter((name) => name.endsWith(".json"))
144
+ .sort(compareEnvelopeNames);
145
+ const latest = names.at(-1);
146
+ if (latest)
147
+ envelopeTail = readTail(path.join(runDir, "envelopes", latest), OUTPUT_TAIL_BYTES);
148
+ }
149
+ catch (error) {
150
+ if (error.code !== "ENOENT") {
151
+ process.stderr.write(`hq lanes: could not read status envelopes for ${lane.lane_id}: ${error instanceof Error ? error.message : String(error)}\n`);
152
+ }
153
+ }
154
+ const prSources = [
155
+ ...outputTails.map(({ text, mtimeMs }) => ({ text, mtimeMs })),
156
+ ...(envelopeTail ? [{ text: envelopeTail.text, mtimeMs: envelopeTail.mtimeMs }] : []),
157
+ ].sort((left, right) => left.mtimeMs - right.mtimeMs);
158
+ const pr = lastPr(prSources.map(({ text }) => text).join("\n"));
159
+ const inboxTail = readTail(path.join(runDir, "dropbox", "inbox.jsonl"), INBOX_TAIL_BYTES);
160
+ const inboxPending = inboxTail === null ? 0
161
+ : inboxTail.size > INBOX_TAIL_BYTES ? null
162
+ : inboxTail.text.split(/\r?\n/).filter((line) => {
163
+ if (!line.trim())
164
+ return false;
165
+ try {
166
+ return JSON.parse(line) !== null;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }).length;
172
+ const startedAt = startTimeMs(lane, runDir);
173
+ const terminal = isTerminalLaneState(lane.state);
174
+ const terminalEndAt = [lane.ended_at, lane.updated_at]
175
+ .map((value) => typeof value === "string" ? Date.parse(value) : NaN)
176
+ .find(Number.isFinite);
177
+ const endedAt = terminal ? terminalEndAt ?? nowMs : nowMs;
178
+ const elapsed = startedAt === null || !Number.isFinite(endedAt)
179
+ ? null
180
+ : Math.max(0, Math.floor((endedAt - startedAt) / 1000));
181
+ return {
182
+ last_line: safeLastLine(lines),
183
+ pr,
184
+ inbox_pending: inboxPending,
185
+ elapsed_s: elapsed,
186
+ exit: terminalExit(lane, runDir),
187
+ };
188
+ }
189
+ //# sourceMappingURL=status-fields.js.map
@@ -238,7 +238,7 @@ export const LANE_FIELD_WRITERS = {
238
238
  provider: ["creator", "command"], provider_source: ["creator", "command"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_source: ["creator", "command"], profile_settings_pinned: ["command"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], fallback_question_cancellation_failures: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
239
239
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
240
240
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
241
- last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
241
+ last_message_at: ["creator", "command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
242
242
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
243
243
  admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile", "spawn"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
244
244
  quota_reset_at: ["watcher", "reconcile", "spawn", "command"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
@@ -0,0 +1,18 @@
1
+ import { type LaneRecord, type LaneProvider, type SeniorRef } from "./types.js";
2
+ export interface WorkerContinuityLookup {
3
+ senior: SeniorRef;
4
+ worker: string;
5
+ provider: LaneProvider;
6
+ company: LaneRecord["company"];
7
+ freshThread?: boolean;
8
+ }
9
+ export interface WorkerContinuityResult {
10
+ previous?: LaneRecord;
11
+ resumable: boolean;
12
+ fallbackReason?: "provider_unsupported" | "session_id_missing" | "state_unsafe" | "host_mismatch";
13
+ }
14
+ export declare function sameLaneHost(left: LaneRecord["host"], right: LaneRecord["host"]): boolean;
15
+ /** Select the prior worker lane within exactly one senior/company/provider scope. */
16
+ export declare function findWorkerContinuityLane(lanes: readonly LaneRecord[], lookup: WorkerContinuityLookup): WorkerContinuityResult;
17
+ export declare function workerContinuityPrompt(previous: LaneRecord, resultSummary?: string, resultRef?: string): string;
18
+ //# sourceMappingURL=worker-continuity.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { LanesError } from "./errors.js";
2
+ import { providerSupportsSessionResume } from "./delivery.js";
3
+ import { isTerminalLaneState } from "./types.js";
4
+ function seniorKey(senior) {
5
+ return `${senior.kind}:${senior.id ?? ""}`;
6
+ }
7
+ function sameCompany(left, right) {
8
+ if (left?.uid && right?.uid)
9
+ return left.uid === right.uid;
10
+ if (left?.slug && right?.slug)
11
+ return left.slug === right.slug;
12
+ return left == null && right == null;
13
+ }
14
+ function compareNewest(left, right) {
15
+ return right.created_at.localeCompare(left.created_at) || right.lane_id.localeCompare(left.lane_id);
16
+ }
17
+ export function sameLaneHost(left, right) {
18
+ return left.kind === right.kind && left.id === right.id;
19
+ }
20
+ /** Select the prior worker lane within exactly one senior/company/provider scope. */
21
+ export function findWorkerContinuityLane(lanes, lookup) {
22
+ const scoped = lanes
23
+ .filter((lane) => lane.worker === lookup.worker && lane.provider === lookup.provider &&
24
+ seniorKey(lane.senior) === seniorKey(lookup.senior) && sameCompany(lane.company ?? lane.mesh_company, lookup.company))
25
+ .sort(compareNewest);
26
+ const live = scoped.find((lane) => !isTerminalLaneState(lane.state));
27
+ if (live) {
28
+ throw new LanesError("worker_lane_busy", `Worker ${lookup.worker} already has live lane ${live.lane_id}`, {
29
+ worker: lookup.worker,
30
+ lane_id: live.lane_id,
31
+ state: live.state,
32
+ });
33
+ }
34
+ const previous = scoped[0];
35
+ if (!previous)
36
+ return { resumable: false };
37
+ if (lookup.freshThread)
38
+ return { previous, resumable: false };
39
+ const previousSessionId = previous.provider_session_id?.trim();
40
+ if (previousSessionId) {
41
+ const liveDescendant = lanes
42
+ .filter((lane) => lane.lane_id !== previous.lane_id && lane.provider === lookup.provider &&
43
+ sameCompany(lane.company ?? lane.mesh_company, lookup.company) &&
44
+ lane.provider_session_id === previousSessionId &&
45
+ (lane.state === "queued" || lane.state === "running"))
46
+ .sort(compareNewest)[0];
47
+ if (liveDescendant) {
48
+ throw new LanesError("worker_lane_busy", `Provider thread lineage is active in lane ${liveDescendant.lane_id}`, {
49
+ worker: lookup.worker,
50
+ lane_id: liveDescendant.lane_id,
51
+ state: liveDescendant.state,
52
+ provider_session_id: previousSessionId,
53
+ });
54
+ }
55
+ }
56
+ // Only clean, resumable terminal outcomes are safe for automatic reuse.
57
+ if (previous.state !== "done" && previous.state !== "interrupted") {
58
+ return { previous, resumable: false, fallbackReason: "state_unsafe" };
59
+ }
60
+ if (!providerSupportsSessionResume(lookup.provider)) {
61
+ return { previous, resumable: false, fallbackReason: "provider_unsupported" };
62
+ }
63
+ if (!previous.provider_session_id?.trim()) {
64
+ return { previous, resumable: false, fallbackReason: "session_id_missing" };
65
+ }
66
+ return { previous, resumable: true };
67
+ }
68
+ export function workerContinuityPrompt(previous, resultSummary, resultRef) {
69
+ const summary = resultSummary?.trim();
70
+ const resultPath = resultRef?.trim()
71
+ ? `workspace/lanes-runs/${previous.lane_id}/${resultRef.trim()}`
72
+ : `workspace/lanes-runs/${previous.lane_id}/envelopes/`;
73
+ return [
74
+ "## Previous worker lane",
75
+ `Continue the provider session from lane ${previous.lane_id}. Its latest result is recorded at ${resultPath}; read that result before acting.`,
76
+ ...(summary ? [`Previous result summary: ${summary}`] : []),
77
+ "The current brief below is the instruction for this turn.",
78
+ "",
79
+ ].join("\n");
80
+ }
81
+ //# sourceMappingURL=worker-continuity.js.map
@@ -0,0 +1,4 @@
1
+ export declare const LANE_WORKER_EXIT_PATH_ENV = "HQ_LANE_WORKER_EXIT_PATH";
2
+ /** Runs one lane worker process and persists its terminal process status. */
3
+ export declare function runLaneWorkerProcess(command: string, args: string[], env?: NodeJS.ProcessEnv, cwd?: string): Promise<number>;
4
+ //# sourceMappingURL=worker-process.d.ts.map
@@ -0,0 +1,77 @@
1
+ import * as childProcess from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ export const LANE_WORKER_EXIT_PATH_ENV = "HQ_LANE_WORKER_EXIT_PATH";
7
+ function exitCodeFor(code, signal) {
8
+ if (code !== null)
9
+ return code;
10
+ return signal ? 128 + (os.constants.signals[signal] ?? 0) : 1;
11
+ }
12
+ function recordExit(pathname, code, signal) {
13
+ const tempPath = `${pathname}.${process.pid}.tmp`;
14
+ fs.writeFileSync(tempPath, `${JSON.stringify({
15
+ exit_code: exitCodeFor(code, signal),
16
+ ...(signal ? { signal } : {}),
17
+ ended_at: new Date().toISOString(),
18
+ })}\n`, { mode: 0o600 });
19
+ fs.renameSync(tempPath, pathname);
20
+ }
21
+ /** Runs one lane worker process and persists its terminal process status. */
22
+ export function runLaneWorkerProcess(command, args, env = process.env, cwd = process.cwd()) {
23
+ const exitPath = env[LANE_WORKER_EXIT_PATH_ENV];
24
+ const childEnv = { ...env };
25
+ delete childEnv[LANE_WORKER_EXIT_PATH_ENV];
26
+ const child = childProcess.spawn(command, args, { cwd, env: childEnv, stdio: "inherit" });
27
+ let settled = false;
28
+ const finish = (code, signal) => {
29
+ if (!settled && exitPath) {
30
+ try {
31
+ recordExit(exitPath, code, signal);
32
+ }
33
+ catch (error) {
34
+ process.stderr.write(`hq lanes: could not persist worker exit status: ${error instanceof Error ? error.message : String(error)}\n`);
35
+ }
36
+ }
37
+ settled = true;
38
+ return exitCodeFor(code, signal);
39
+ };
40
+ return new Promise((resolve) => {
41
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
42
+ const handlers = new Map();
43
+ const cleanup = () => {
44
+ for (const [signal, handler] of handlers)
45
+ process.removeListener(signal, handler);
46
+ };
47
+ for (const signal of signals) {
48
+ const handler = () => child.kill(signal);
49
+ handlers.set(signal, handler);
50
+ process.on(signal, handler);
51
+ }
52
+ child.once("error", (error) => {
53
+ process.stderr.write(`hq lanes: worker process could not start: ${error.message}\n`);
54
+ if (!settled) {
55
+ cleanup();
56
+ resolve(finish(127, null));
57
+ }
58
+ });
59
+ child.once("exit", (code, signal) => {
60
+ if (!settled) {
61
+ cleanup();
62
+ resolve(finish(code, signal));
63
+ }
64
+ });
65
+ });
66
+ }
67
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
68
+ const [command, ...args] = process.argv.slice(2);
69
+ if (!command) {
70
+ process.stderr.write("hq lanes: worker process command is missing\n");
71
+ process.exitCode = 2;
72
+ }
73
+ else {
74
+ process.exitCode = await runLaneWorkerProcess(command, args);
75
+ }
76
+ }
77
+ //# sourceMappingURL=worker-process.js.map
package/dist/main.js CHANGED
@@ -47,7 +47,7 @@ import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, reso
47
47
  import { autoUpdateAndReexec } from "./utils/self-update.js";
48
48
  import { CLI_VERSION } from "./cli-version.js";
49
49
  import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
50
- import { emitCliFirstSyncNextStepOutcome, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliInviteNudgeEvent, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takeCliInviteNudgeEvents, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
50
+ import { emitCliFirstSyncNextStepOutcome, emitCliFeatureUsed, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliInviteNudgeEvent, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliFeatureUsages, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takeCliInviteNudgeEvents, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
51
51
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
52
52
  import { settleWithin } from "./utils/settle-with-timeout.js";
53
53
  import { isVersionRequest } from "./version-request.js";
@@ -298,7 +298,9 @@ export async function runCli() {
298
298
  await releasePackageUseLeaseAfterCommandLoad(process.argv);
299
299
  await program.parseAsync();
300
300
  commandParsed = true;
301
- maybeShowFirstSyncNextStep();
301
+ if (process.exitCode === undefined || process.exitCode === 0) {
302
+ maybeShowFirstSyncNextStep();
303
+ }
302
304
  }
303
305
  catch (err) {
304
306
  await handleTopLevelError(err);
@@ -316,7 +318,10 @@ export async function runCli() {
316
318
  // lifecycle boundary for this invocation. The budget reserves this 500 ms
317
319
  // slice even when the command itself was long-running.
318
320
  const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
319
- const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
321
+ const firstSyncOutcomeFlushes = process.exitCode === undefined || process.exitCode === 0
322
+ ? takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs))
323
+ : [];
324
+ const featureUsageFlushes = takeCliFeatureUsages().map((usage) => emitCliFeatureUsed(usage, releaseHealthTimeoutMs));
320
325
  const inviteNudgeOutcomeFlushes = takeCliInviteNudgeEvents().map((event) => emitCliInviteNudgeEvent(event, releaseHealthTimeoutMs));
321
326
  const inviteTeammateExposureFlushes = takePlanLimitInviteTeammateExposures().map((companyUid) => emitPlanLimitInviteTeammateExposure(companyUid, releaseHealthTimeoutMs));
322
327
  const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
@@ -343,6 +348,7 @@ export async function runCli() {
343
348
  ...(cachedVersionNudge ? [cachedVersionNudge] : []),
344
349
  Sentry.flush(releaseHealthTimeoutMs),
345
350
  ...firstSyncOutcomeFlushes,
351
+ ...featureUsageFlushes,
346
352
  ...inviteNudgeOutcomeFlushes,
347
353
  ...inviteTeammateExposureFlushes,
348
354
  ...limitNoticeExposureFlushes,
@@ -46,7 +46,7 @@ export declare function queueCliInviteNudgeEvent(companyUid: string, outcome: Cl
46
46
  export declare function takeCliInviteNudgeEvents(): CliInviteNudgeEvent[];
47
47
  /** Emit a bounded CLI session fact through the existing product-telemetry path. */
48
48
  export declare function emitCliInviteNudgeEvent(event: CliInviteNudgeEvent, timeoutMs?: number): Promise<void>;
49
- export type CliFeatureKind = "secret_read" | "lane_create" | "lane_resume";
49
+ export type CliFeatureKind = "secret_read" | "lane_create" | "lane_resume" | "members_invite";
50
50
  export type CliFeatureOutcome = "success" | "failure";
51
51
  export interface CliFeatureUsage {
52
52
  kind: CliFeatureKind;
@@ -54,6 +54,9 @@ export interface CliFeatureUsage {
54
54
  /** Only company UIDs are accepted; names and slugs are never telemetry fields. */
55
55
  companyUid?: string;
56
56
  }
57
+ /** Queue privacy-limited feature telemetry for the bounded CLI finalization flush. */
58
+ export declare function queueCliFeatureUsed(usage: CliFeatureUsage): void;
59
+ export declare function takeCliFeatureUsages(): CliFeatureUsage[];
57
60
  export declare function isCliLimitResourceKind(value: string): value is CliLimitResourceKind;
58
61
  /**
59
62
  * Emit one bounded, privacy-limited feature-use event with a cached identity.
@@ -142,6 +142,14 @@ export async function emitCliInviteNudgeEvent(event, timeoutMs = TELEMETRY_TIMEO
142
142
  console.error(`hq: Could not prepare invite prompt telemetry (${errorClass}).`);
143
143
  }
144
144
  }
145
+ const pendingCliFeatureUsages = [];
146
+ /** Queue privacy-limited feature telemetry for the bounded CLI finalization flush. */
147
+ export function queueCliFeatureUsed(usage) {
148
+ pendingCliFeatureUsages.push({ ...usage });
149
+ }
150
+ export function takeCliFeatureUsages() {
151
+ return pendingCliFeatureUsages.splice(0, pendingCliFeatureUsages.length);
152
+ }
145
153
  const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9_-]{1,128}$/;
146
154
  export function isCliLimitResourceKind(value) {
147
155
  return ["users", "integrations", "secrets", "agents", "deployments", "storageBytes"].includes(value);
@@ -0,0 +1,13 @@
1
+ export type JqResult = {
2
+ code: number;
3
+ stdout?: string;
4
+ stderr?: string;
5
+ stdoutBytes?: Buffer;
6
+ stderrBytes?: Buffer;
7
+ };
8
+ export type JqInvoke = (args: string[], input: Buffer | string | undefined, cwd: string, options: {
9
+ stdout?: "capture" | "ignore";
10
+ stderr?: "pipe" | "ignore";
11
+ }) => JqResult;
12
+ export declare function createShellCompatibleJqInvoker(invoke: JqInvoke, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, resolveShellType: (cwd: string) => string | undefined): JqInvoke;
13
+ //# sourceMappingURL=jq-windows-compat.d.ts.map
@@ -0,0 +1,48 @@
1
+ function stripCarriageReturnBeforeLineFeed(bytes) {
2
+ const output = Buffer.allocUnsafe(bytes.length);
3
+ let length = 0;
4
+ for (let index = 0; index < bytes.length; index += 1) {
5
+ const byte = bytes[index];
6
+ if (byte === 0x0d && bytes[index + 1] === 0x0a)
7
+ continue;
8
+ output[length] = byte ?? 0;
9
+ length += 1;
10
+ }
11
+ return output.subarray(0, length);
12
+ }
13
+ function normalizeResult(result) {
14
+ const stdoutBytes = stripCarriageReturnBeforeLineFeed(result.stdoutBytes ?? Buffer.from(result.stdout ?? ""));
15
+ const stderrBytes = stripCarriageReturnBeforeLineFeed(result.stderrBytes ?? Buffer.from(result.stderr ?? ""));
16
+ return {
17
+ ...result,
18
+ stdoutBytes,
19
+ stderrBytes,
20
+ stdout: stdoutBytes.toString("utf8"),
21
+ stderr: stderrBytes.toString("utf8"),
22
+ };
23
+ }
24
+ export function createShellCompatibleJqInvoker(invoke, platform, env, resolveShellType) {
25
+ let binaryOptionSupported;
26
+ let shellTypeResolved = false;
27
+ let shellType;
28
+ return (args, input, cwd, options) => {
29
+ if (platform !== "win32")
30
+ return invoke(args, input, cwd, options);
31
+ if (!shellTypeResolved) {
32
+ shellType = resolveShellType(cwd);
33
+ shellTypeResolved = true;
34
+ }
35
+ const msysMode = /^msys/i.test(shellType ?? "") || /^MINGW/i.test(env.MSYSTEM ?? "");
36
+ if (!msysMode)
37
+ return invoke(args, input, cwd, options);
38
+ if (binaryOptionSupported === undefined) {
39
+ binaryOptionSupported = invoke(["--binary", "-n", "null"], undefined, cwd, {
40
+ stdout: "ignore",
41
+ stderr: "ignore",
42
+ }).code === 0;
43
+ }
44
+ const result = invoke(binaryOptionSupported ? ["--binary", ...args] : args, input, cwd, options);
45
+ return binaryOptionSupported ? result : normalizeResult(result);
46
+ };
47
+ }
48
+ //# sourceMappingURL=jq-windows-compat.js.map
@@ -29,6 +29,7 @@
29
29
  * - signals are surfaced as the conventional 128+n. A caller that kills a
30
30
  * script needs to see termination, not a success code.
31
31
  */
32
+ export { resolveWindowsBash } from "./windows-bash.js";
32
33
  /**
33
34
  * Which interpreter runs a bundled asset, chosen by extension.
34
35
  *
@@ -57,6 +58,8 @@ export type RunBundledScriptOptions = {
57
58
  captureOutput?: boolean;
58
59
  /** Bytes supplied on stdin. With no value, stdin is inherited as before. */
59
60
  stdin?: string | Buffer;
61
+ /** Start the bundled Bash script with descriptor 0 closed. */
62
+ closeStdin?: boolean;
60
63
  /** Optional child-process deadline for callers that need bounded script execution. */
61
64
  timeoutMs?: number;
62
65
  };
@@ -34,6 +34,8 @@ import * as fs from "node:fs";
34
34
  import * as os from "node:os";
35
35
  import * as path from "path";
36
36
  import { resolveBundledAsset } from "./hq-roots-core.js";
37
+ import { resolveWindowsBash } from "./windows-bash.js";
38
+ export { resolveWindowsBash } from "./windows-bash.js";
37
39
  /**
38
40
  * Which interpreter runs a bundled asset, chosen by extension.
39
41
  *
@@ -52,85 +54,6 @@ function scriptPathForBash(script) {
52
54
  const drivePath = normalized.match(/^([A-Za-z]):\/(.*)$/);
53
55
  return drivePath ? `/${drivePath[1].toLowerCase()}/${drivePath[2]}` : normalized;
54
56
  }
55
- function envValue(env, name) {
56
- const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
57
- return key ? env[key] : undefined;
58
- }
59
- function isFile(file, lookupErrors) {
60
- try {
61
- return fs.statSync(file).isFile();
62
- }
63
- catch (error) {
64
- // Missing PATH candidates are expected during lookup; retain the cause so
65
- // the final actionable error can expose why no interpreter was resolved.
66
- lookupErrors.push(error);
67
- return false;
68
- }
69
- }
70
- function isWithinWindowsDirectory(file, directory) {
71
- const normalizedFile = path.win32.resolve(file).toLowerCase();
72
- const normalizedDirectory = path.win32.resolve(directory).toLowerCase();
73
- return (normalizedFile === normalizedDirectory ||
74
- normalizedFile.startsWith(`${normalizedDirectory}${path.win32.sep}`));
75
- }
76
- function isExcludedWindowsBash(file, env) {
77
- if (path.win32.basename(file).toLowerCase() !== "bash.exe")
78
- return false;
79
- const windowsDir = envValue(env, "SystemRoot") ?? envValue(env, "WINDIR") ?? "C:\\Windows";
80
- const systemBash = path.win32.resolve(windowsDir, "System32", "bash.exe");
81
- if (path.win32.resolve(file).toLowerCase() === systemBash.toLowerCase())
82
- return true;
83
- const localAppData = envValue(env, "LOCALAPPDATA");
84
- return Boolean(localAppData &&
85
- isWithinWindowsDirectory(file, path.win32.resolve(localAppData, "Microsoft", "WindowsApps")));
86
- }
87
- function findOnWindowsPath(executable, env, lookupErrors) {
88
- const pathValue = envValue(env, "PATH") ?? "";
89
- const extensions = executable.includes(".")
90
- ? [""]
91
- : (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
92
- .split(";")
93
- .filter(Boolean)
94
- .map((ext) => ext.toLowerCase());
95
- for (const entry of pathValue.split(";").filter(Boolean)) {
96
- const directory = entry.trim().replace(/^"|"$/g, "");
97
- for (const extension of extensions) {
98
- const candidate = path.win32.join(directory, `${executable}${extension}`);
99
- if (!isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors))
100
- return candidate;
101
- }
102
- }
103
- return undefined;
104
- }
105
- function resolveWindowsBash(env) {
106
- const lookupErrors = [];
107
- const configured = envValue(env, "CLAUDE_CODE_GIT_BASH_PATH");
108
- if (configured && !isExcludedWindowsBash(configured, env) && isFile(configured, lookupErrors)) {
109
- return { executable: configured, cause: lookupErrors.at(-1) };
110
- }
111
- const pathBash = findOnWindowsPath("bash", env, lookupErrors);
112
- if (pathBash)
113
- return { executable: pathBash, cause: lookupErrors.at(-1) };
114
- const gitExe = findOnWindowsPath("git.exe", env, lookupErrors);
115
- if (gitExe) {
116
- const derived = path.win32.resolve(path.win32.dirname(gitExe), "..", "bin", "bash.exe");
117
- if (!isExcludedWindowsBash(derived, env) && isFile(derived, lookupErrors)) {
118
- return { executable: derived, cause: lookupErrors.at(-1) };
119
- }
120
- }
121
- const programFiles = envValue(env, "ProgramFiles") ?? "C:\\Program Files";
122
- const localAppData = envValue(env, "LOCALAPPDATA");
123
- const standardPaths = [
124
- path.win32.join(programFiles, "Git", "bin", "bash.exe"),
125
- ...(localAppData ? [path.win32.join(localAppData, "Programs", "Git", "bin", "bash.exe")] : []),
126
- ];
127
- const executable = standardPaths.find((candidate) => !isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors));
128
- return {
129
- executable,
130
- cause: lookupErrors.at(-1) ??
131
- Object.assign(new Error("Bash was not found (ENOENT)"), { code: "ENOENT" }),
132
- };
133
- }
134
57
  function bashNotFoundError(asset, cause) {
135
58
  const isHandoff = asset.replaceAll("\\", "/").endsWith("core/scripts/handoff-finalize.sh");
136
59
  const recovery = isHandoff
@@ -185,7 +108,13 @@ export function runBundledScript(opts) {
185
108
  ? [stdinStdio, "inherit", "inherit"]
186
109
  : "inherit";
187
110
  const scriptArg = interpreter === "bash" && opts.msysScriptPath ? scriptPathForBash(script) : script;
188
- result = spawnSync(executable, [scriptArg, ...(opts.args ?? [])], {
111
+ if (opts.closeStdin && (interpreter !== "bash" || inputDescriptor !== undefined)) {
112
+ throw new Error("closeStdin requires a Bash asset without supplied stdin");
113
+ }
114
+ const commandArgs = opts.closeStdin
115
+ ? ["-c", 'exec 0<&-; exec "$BASH" "$@"', "hq-bundled-script", scriptArg, ...(opts.args ?? [])]
116
+ : [scriptArg, ...(opts.args ?? [])];
117
+ result = spawnSync(executable, commandArgs, {
189
118
  cwd: opts.cwd,
190
119
  env,
191
120
  stdio,
@@ -0,0 +1,5 @@
1
+ export declare function resolveWindowsBash(env: Record<string, string | undefined>): {
2
+ executable?: string;
3
+ cause: unknown;
4
+ };
5
+ //# sourceMappingURL=windows-bash.d.ts.map