@indigoai-us/hq-cli 5.345.198 → 5.345.200

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 (129) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
  3. package/dist/command-catalog.generated.d.ts +150 -6
  4. package/dist/command-catalog.generated.js +182 -6
  5. package/dist/commands/bot-events.d.ts +19 -0
  6. package/dist/commands/bot-events.js +46 -0
  7. package/dist/commands/bot-jobs.d.ts +2 -0
  8. package/dist/commands/bot-jobs.js +43 -2
  9. package/dist/commands/bot-runtime.d.ts +35 -0
  10. package/dist/commands/bot-runtime.js +115 -0
  11. package/dist/commands/bot.d.ts +11 -0
  12. package/dist/commands/bot.js +72 -11
  13. package/dist/commands/creators.js +2 -1
  14. package/dist/commands/lanes-report.d.ts +2 -1
  15. package/dist/commands/lanes-report.js +44 -1
  16. package/dist/commands/lanes.d.ts +9 -0
  17. package/dist/commands/lanes.js +95 -9
  18. package/dist/commands/library-remove.js +2 -1
  19. package/dist/commands/library.js +5 -4
  20. package/dist/commands/meetings.js +1 -21
  21. package/dist/commands/mesh.d.ts +0 -7
  22. package/dist/commands/mesh.js +5 -1
  23. package/dist/commands/signals-run.d.ts +2 -0
  24. package/dist/commands/signals-run.js +6 -1
  25. package/dist/commands/sync-held.js +2 -2
  26. package/dist/lanes-drain-hook.js +9 -0
  27. package/dist/lib/bot/api.d.ts +5 -0
  28. package/dist/lib/bot/claude-login.d.ts +31 -0
  29. package/dist/lib/bot/claude-login.js +160 -0
  30. package/dist/lib/bot/events.d.ts +18 -0
  31. package/dist/lib/bot/events.js +160 -0
  32. package/dist/lib/bot/executive-settings.d.ts +10 -0
  33. package/dist/lib/bot/executive-settings.js +43 -0
  34. package/dist/lib/bot/fleet-cron.js +4 -0
  35. package/dist/lib/bot/fleet-download.d.ts +27 -0
  36. package/dist/lib/bot/fleet-download.js +162 -0
  37. package/dist/lib/bot/fleet-local.d.ts +51 -2
  38. package/dist/lib/bot/fleet-local.js +179 -76
  39. package/dist/lib/bot/fleet-python.d.ts +2 -0
  40. package/dist/lib/bot/fleet-python.js +29 -0
  41. package/dist/lib/bot/job-run.js +1 -1
  42. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  43. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  44. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  45. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  46. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  47. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  48. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  49. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  50. package/dist/lib/bot/local-jobs.d.ts +15 -2
  51. package/dist/lib/bot/local-jobs.js +24 -7
  52. package/dist/lib/bot/run.d.ts +7 -49
  53. package/dist/lib/bot/run.js +100 -4
  54. package/dist/lib/bot/status.d.ts +21 -0
  55. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  56. package/dist/lib/doctor/checks/bots.js +47 -1
  57. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  58. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  59. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  60. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  61. package/dist/lib/hot-channels/schedule.js +2 -2
  62. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  63. package/dist/lib/hot-channels/signals-pass.js +51 -17
  64. package/dist/lib/lanes/admission.d.ts +2 -0
  65. package/dist/lib/lanes/admission.js +4 -0
  66. package/dist/lib/lanes/approval-gate.js +4 -1
  67. package/dist/lib/lanes/links.d.ts +8 -1
  68. package/dist/lib/lanes/links.js +84 -3
  69. package/dist/lib/lanes/mesh-events.js +3 -1
  70. package/dist/lib/lanes/outcome.d.ts +154 -0
  71. package/dist/lib/lanes/outcome.js +151 -0
  72. package/dist/lib/lanes/reconcile.js +1 -0
  73. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  74. package/dist/lib/lanes/signal-brief-template.js +40 -0
  75. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  76. package/dist/lib/lanes/signal-lane.js +99 -0
  77. package/dist/lib/lanes/source-report.d.ts +37 -0
  78. package/dist/lib/lanes/source-report.js +66 -0
  79. package/dist/lib/lanes/spawn.d.ts +6 -0
  80. package/dist/lib/lanes/spawn.js +21 -0
  81. package/dist/lib/lanes/store.d.ts +3 -0
  82. package/dist/lib/lanes/store.js +64 -0
  83. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  84. package/dist/lib/lanes/story-ledger.js +1 -0
  85. package/dist/lib/lanes/types.d.ts +137 -0
  86. package/dist/lib/lanes/types.js +17 -2
  87. package/dist/lib/lanes/watcher.js +4 -0
  88. package/dist/lib/live-meetings.d.ts +19 -0
  89. package/dist/lib/live-meetings.js +23 -0
  90. package/dist/lib/mesh/api.js +2 -1
  91. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  92. package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
  93. package/dist/lib/mesh/live/daemon/run.js +3 -0
  94. package/dist/lib/mesh/live/emit.d.ts +2 -11
  95. package/dist/lib/mesh/live/emit.js +20 -2
  96. package/dist/lib/mesh/live/flush.d.ts +2 -0
  97. package/dist/lib/mesh/live/flush.js +34 -3
  98. package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
  99. package/dist/lib/mesh/live/reporting-privacy.js +47 -0
  100. package/dist/lib/mesh/live/spool.d.ts +8 -0
  101. package/dist/lib/mesh/live/spool.js +59 -40
  102. package/dist/lib/plan-limit-nag.d.ts +30 -4
  103. package/dist/lib/plan-limit-nag.js +68 -17
  104. package/dist/lib/signals/engine.d.ts +4 -0
  105. package/dist/lib/signals/engine.js +25 -5
  106. package/dist/lib/signals/incremental.d.ts +42 -0
  107. package/dist/lib/signals/incremental.js +191 -0
  108. package/dist/lib/signals/prior-signals.d.ts +11 -0
  109. package/dist/lib/signals/prior-signals.js +44 -0
  110. package/dist/lib/signals/reply.js +6 -1
  111. package/dist/lib/signals/settings.d.ts +5 -0
  112. package/dist/lib/signals/settings.js +17 -0
  113. package/dist/lib/signals/steps/extract.d.ts +23 -2
  114. package/dist/lib/signals/steps/extract.js +142 -9
  115. package/dist/lib/signals/steps/types.d.ts +1 -0
  116. package/dist/lib/signals/write-guard.js +7 -0
  117. package/dist/lib/work-context/company.d.ts +2 -3
  118. package/dist/lib/work-context/company.js +4 -22
  119. package/dist/secrets-env-action.js +5 -2
  120. package/dist/utils/cognito-callback-port.d.ts +17 -0
  121. package/dist/utils/cognito-callback-port.js +56 -0
  122. package/dist/utils/cognito-config.js +7 -3
  123. package/dist/utils/cognito-session.d.ts +7 -6
  124. package/dist/utils/cognito-session.js +19 -14
  125. package/dist/utils/interactive-caller.d.ts +23 -0
  126. package/dist/utils/interactive-caller.js +80 -0
  127. package/dist/utils/login-browser.js +5 -2
  128. package/dist/utils/resident-package-preload.js +4 -0
  129. package/package.json +7 -7
@@ -0,0 +1,160 @@
1
+ /** Local event journal and durable inbox. Cursor and queued deliveries commit together. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readLocalJobsStrict, withJobsLock } from "./local-jobs.js";
6
+ /** Rotate journals at five MiB without discarding unread records. */
7
+ export const EVENT_ROTATE_BYTES = 5 * 1024 * 1024;
8
+ export const EVENT_PAYLOAD_BYTES = 16 * 1024;
9
+ const journal = "events.jsonl";
10
+ const stateFile = "events-state.json";
11
+ export function validateEventPayload(payload) {
12
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
13
+ throw new Error("Event payload must be a JSON object.");
14
+ if (Buffer.byteLength(JSON.stringify(payload), "utf8") >= EVENT_PAYLOAD_BYTES)
15
+ throw new Error("Event payload must be under 16 KB.");
16
+ }
17
+ function readState(dir) {
18
+ try {
19
+ const state = JSON.parse(fs.readFileSync(path.join(dir, stateFile), "utf8"));
20
+ if (state.v !== 1 || !state.cursors || !Array.isArray(state.pending))
21
+ throw new Error("Invalid event state");
22
+ return state;
23
+ }
24
+ catch (err) {
25
+ if (err.code === "ENOENT")
26
+ return { v: 1, cursors: {}, pending: [] };
27
+ throw err; // Never reset a corrupt cursor and replay the journal.
28
+ }
29
+ }
30
+ function writeState(dir, state) {
31
+ const file = path.join(dir, stateFile);
32
+ const tmp = `${file}.${process.pid}.tmp`;
33
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
34
+ fs.renameSync(tmp, file);
35
+ }
36
+ function files(dir) {
37
+ return fs.readdirSync(dir).filter((f) => /^events\.\d+-[a-f0-9-]+\.jsonl$/.test(f)).sort().concat(journal);
38
+ }
39
+ /** A persisted intent makes both sides of the journal rename recoverable. */
40
+ function recoverRotation(dir, state) {
41
+ if (!state.rotating)
42
+ return;
43
+ const archived = state.rotating;
44
+ if (!fs.existsSync(path.join(dir, archived))) {
45
+ fs.renameSync(path.join(dir, journal), path.join(dir, archived));
46
+ }
47
+ state.cursors[archived] = state.cursors[journal] ?? 0;
48
+ delete state.cursors[journal];
49
+ delete state.rotating;
50
+ writeState(dir, state);
51
+ }
52
+ export function emitBotEvent(dir, event, payload, now = new Date(), alreadyEmitted) {
53
+ if (!event.trim() || event.length > 256 || /[\r\n]/.test(event))
54
+ throw new Error("Event name must be 1–256 characters without newlines.");
55
+ validateEventPayload(payload);
56
+ const record = { event, at: now.toISOString(), payload };
57
+ return withJobsLock(dir, () => {
58
+ const state = readState(dir);
59
+ recoverRotation(dir, state);
60
+ // Optional producer idempotency: scan and append under the same lock as
61
+ // rotation, including archived journals and only fully written lines.
62
+ if (alreadyEmitted) {
63
+ for (const name of files(dir)) {
64
+ const candidate = path.join(dir, name);
65
+ if (!fs.existsSync(candidate))
66
+ continue;
67
+ const lines = fs.readFileSync(candidate, "utf8").split("\n").slice(0, -1);
68
+ for (const line of lines.filter(Boolean)) {
69
+ const prior = JSON.parse(line);
70
+ if (alreadyEmitted(prior))
71
+ return prior;
72
+ }
73
+ }
74
+ }
75
+ const file = path.join(dir, journal);
76
+ const bytes = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
77
+ // A process killed during append may leave an incomplete final line.
78
+ // It was never accepted as an event: discard it before the next append,
79
+ // otherwise that next valid record would join it and poison the drain.
80
+ const size = bytes.length && bytes.at(-1) !== 10 ? bytes.lastIndexOf(10) + 1 : bytes.length;
81
+ if (size !== bytes.length)
82
+ fs.truncateSync(file, size);
83
+ if (size >= EVENT_ROTATE_BYTES) {
84
+ state.rotating = `events.${Date.now()}-${randomUUID()}.jsonl`;
85
+ writeState(dir, state);
86
+ recoverRotation(dir, state);
87
+ }
88
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
89
+ return record;
90
+ });
91
+ }
92
+ export function eventMatches(job, record) {
93
+ return job.trigger?.kind === "event" && job.trigger.event === record.event &&
94
+ Object.entries(job.trigger.filter ?? {}).every(([key, value]) => {
95
+ if (!Object.hasOwn(record.payload, key))
96
+ return false;
97
+ // JSON objects can shadow toString/valueOf with non-callable fields.
98
+ // Such a value cannot match a string filter and must not poison the drain.
99
+ try {
100
+ return String(record.payload[key]) === value;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ });
106
+ }
107
+ /** Pending items survive a crash between queueing and the next poll. */
108
+ export function drainBotEvents(dir) {
109
+ return withJobsLock(dir, () => {
110
+ const state = readState(dir);
111
+ recoverRotation(dir, state);
112
+ const jobs = readLocalJobsStrict(dir);
113
+ for (const name of files(dir)) {
114
+ const file = path.join(dir, name);
115
+ if (!fs.existsSync(file))
116
+ continue;
117
+ let offset = state.cursors[name] ?? 0;
118
+ // Consumed archives need only a stat, not a full read on every poll.
119
+ if (offset === fs.statSync(file).size)
120
+ continue;
121
+ const bytes = fs.readFileSync(file);
122
+ if (offset > bytes.length)
123
+ throw new Error(`Event journal was truncated: ${name}`);
124
+ while (offset < bytes.length) {
125
+ const end = bytes.indexOf(10, offset);
126
+ if (end < 0)
127
+ break; // An interrupted append is never treated as a full event.
128
+ const record = JSON.parse(bytes.subarray(offset, end).toString("utf8"));
129
+ validateEventPayload(record.payload);
130
+ for (const job of jobs.filter((j) => eventMatches(j, record))) {
131
+ state.pending.push({
132
+ messageId: `localjob-event-${randomUUID()}`, channel: "job", jobId: job.jobId,
133
+ prompt: job.prompt, firedAt: record.at, receivedAt: record.at,
134
+ context: { ...job.context, payload: record.payload, event: record },
135
+ });
136
+ }
137
+ offset = end + 1;
138
+ }
139
+ state.cursors[name] = offset;
140
+ }
141
+ writeState(dir, state);
142
+ return state.pending;
143
+ });
144
+ }
145
+ export function ackBotEvent(dir, messageId) {
146
+ withJobsLock(dir, () => {
147
+ const state = readState(dir);
148
+ state.pending = state.pending.filter((item) => item.messageId !== messageId);
149
+ writeState(dir, state);
150
+ });
151
+ }
152
+ export function tailBotEvents(dir, predicate = () => true) {
153
+ return withJobsLock(dir, () => files(dir).flatMap((name) => {
154
+ const file = path.join(dir, name);
155
+ if (!fs.existsSync(file))
156
+ return [];
157
+ return fs.readFileSync(file, "utf8").split("\n").slice(0, -1).filter(Boolean).map((line) => JSON.parse(line));
158
+ }).filter(predicate).slice(-50));
159
+ }
160
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,10 @@
1
+ import { type BotConfig } from "./config.js";
2
+ export declare function executiveRiderEnabled(home?: string): boolean;
3
+ export declare function isExecutiveBot(config: Pick<BotConfig, "name" | "kind" | "workerId">, home?: string): boolean;
4
+ export declare function executiveStatus(config: Pick<BotConfig, "name" | "kind" | "workerId">, dir: string, home?: string): {
5
+ is_executive: boolean;
6
+ rider_enabled: boolean;
7
+ detect_source_last: string | null;
8
+ last_detected_at: string | null;
9
+ };
10
+ //# sourceMappingURL=executive-settings.d.ts.map
@@ -0,0 +1,43 @@
1
+ /** Shared Executive identity and the desktop's default-on rider preference. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { effectiveBotKind } from "./config.js";
6
+ import { tailBotEvents } from "./events.js";
7
+ export function executiveRiderEnabled(home = os.homedir()) {
8
+ try {
9
+ const settings = JSON.parse(fs.readFileSync(path.join(home, ".hq/menubar.json"), "utf8"));
10
+ return settings?.executiveMeetingRider?.enabled !== false;
11
+ }
12
+ catch (err) {
13
+ return err.code === "ENOENT";
14
+ }
15
+ }
16
+ export function isExecutiveBot(config, home = os.homedir()) {
17
+ if (effectiveBotKind(config) !== "personal")
18
+ return false;
19
+ let name = "setup";
20
+ try {
21
+ const value = JSON.parse(fs.readFileSync(path.join(home, ".hq/executive.json"), "utf8"));
22
+ if (value?.bot != null) {
23
+ if (typeof value.bot !== "string" || !value.bot.trim())
24
+ return false;
25
+ name = value.bot;
26
+ }
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "ENOENT")
30
+ return false;
31
+ }
32
+ return config.name === name;
33
+ }
34
+ export function executiveStatus(config, dir, home = os.homedir()) {
35
+ const last = tailBotEvents(dir, (event) => event.event === "meeting.detected").at(-1);
36
+ return {
37
+ is_executive: isExecutiveBot(config, home), rider_enabled: executiveRiderEnabled(home),
38
+ // The first desktop producer did not set source.
39
+ detect_source_last: last ? (last.payload.source === "poll" ? "poll" : "desktop") : null,
40
+ last_detected_at: last?.at ?? null,
41
+ };
42
+ }
43
+ //# sourceMappingURL=executive-settings.js.map
@@ -169,6 +169,10 @@ export function migrateLegacyJobsToFleet(dir, config, ops, now = new Date()) {
169
169
  const skipped = [];
170
170
  const moved = new Set();
171
171
  for (const job of legacy) {
172
+ if (job.trigger || !job.cron) {
173
+ skipped.push(job.jobId);
174
+ continue;
175
+ }
172
176
  let schedule;
173
177
  try {
174
178
  schedule = /^(cron|rate)\(/i.test(job.cron.trim()) ? fleetScheduleFromLegacy(job.cron) : job.cron.trim();
@@ -0,0 +1,27 @@
1
+ /** A download that receives no bytes for this long is treated as stalled and retried. */
2
+ export declare const FLEET_DOWNLOAD_STALL_MS = 90000;
3
+ /** How many times a stalled or broken download is resumed before giving up. */
4
+ export declare const FLEET_DOWNLOAD_MAX_RESUMES = 5;
5
+ export interface FleetDownloadProgress {
6
+ name: string;
7
+ received: number;
8
+ total: number | null;
9
+ }
10
+ export interface FleetDownloadOptions {
11
+ fetch?: typeof fetch;
12
+ headers?: Record<string, string>;
13
+ stallMs?: number;
14
+ maxResumes?: number;
15
+ onProgress?: (progress: FleetDownloadProgress) => void;
16
+ }
17
+ /**
18
+ * Download `url` into `destination`, writing through `<destination>.part` and
19
+ * resuming from the bytes already there. Every attempt that receives nothing
20
+ * for `stallMs` is retried from where it stopped, up to `maxResumes` times.
21
+ * The file is renamed into place only when the body ended cleanly and, when
22
+ * the server told us its size, the sizes match.
23
+ */
24
+ export declare function downloadFleetFile(url: string, destination: string, options?: FleetDownloadOptions): Promise<string>;
25
+ /** sha256 of a file on disk, streamed. */
26
+ export declare function fileSha256(file: string): string;
27
+ //# sourceMappingURL=fleet-download.d.ts.map
@@ -0,0 +1,162 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ /** A download that receives no bytes for this long is treated as stalled and retried. */
5
+ export const FLEET_DOWNLOAD_STALL_MS = 90_000;
6
+ /** How many times a stalled or broken download is resumed before giving up. */
7
+ export const FLEET_DOWNLOAD_MAX_RESUMES = 5;
8
+ function expectedError(message) {
9
+ return Object.assign(new Error(message), { expected: true });
10
+ }
11
+ function partFileSize(file) {
12
+ try {
13
+ return fs.statSync(file).size;
14
+ }
15
+ catch {
16
+ return 0;
17
+ }
18
+ }
19
+ function contentLength(response) {
20
+ const raw = response.headers.get("content-length");
21
+ if (!raw)
22
+ return null;
23
+ const value = Number.parseInt(raw, 10);
24
+ return Number.isFinite(value) && value >= 0 ? value : null;
25
+ }
26
+ function contentRangeTotal(response) {
27
+ const raw = response.headers.get("content-range");
28
+ const match = raw ? /\/(\d+)\s*$/.exec(raw) : null;
29
+ if (!match)
30
+ return null;
31
+ const value = Number.parseInt(match[1], 10);
32
+ return Number.isFinite(value) ? value : null;
33
+ }
34
+ async function pumpBody(response, fd, offset, name, total, stallMs, onProgress) {
35
+ if (!response.body) {
36
+ const bytes = Buffer.from(await response.arrayBuffer());
37
+ fs.writeSync(fd, bytes, 0, bytes.length, offset);
38
+ const received = offset + bytes.length;
39
+ onProgress?.({ name, received, total });
40
+ return received;
41
+ }
42
+ const reader = response.body.getReader();
43
+ let received = offset;
44
+ let lastReport = 0;
45
+ for (;;) {
46
+ let timer;
47
+ const stalled = new Promise((_resolve, reject) => {
48
+ timer = setTimeout(() => reject(expectedError(`stalled: no data for ${Math.round(stallMs / 1000)}s`)), stallMs);
49
+ });
50
+ let chunk;
51
+ try {
52
+ chunk = await Promise.race([reader.read(), stalled]);
53
+ }
54
+ catch (error) {
55
+ void reader.cancel().catch(() => undefined);
56
+ throw error;
57
+ }
58
+ finally {
59
+ if (timer)
60
+ clearTimeout(timer);
61
+ }
62
+ if (chunk.done)
63
+ break;
64
+ fs.writeSync(fd, chunk.value, 0, chunk.value.length, received);
65
+ received += chunk.value.length;
66
+ const now = Date.now();
67
+ if (now - lastReport >= 250 || (total !== null && received >= total)) {
68
+ lastReport = now;
69
+ onProgress?.({ name, received, total });
70
+ }
71
+ }
72
+ onProgress?.({ name, received, total });
73
+ return received;
74
+ }
75
+ /**
76
+ * Download `url` into `destination`, writing through `<destination>.part` and
77
+ * resuming from the bytes already there. Every attempt that receives nothing
78
+ * for `stallMs` is retried from where it stopped, up to `maxResumes` times.
79
+ * The file is renamed into place only when the body ended cleanly and, when
80
+ * the server told us its size, the sizes match.
81
+ */
82
+ export async function downloadFleetFile(url, destination, options = {}) {
83
+ const fetcher = options.fetch ?? fetch;
84
+ const stallMs = options.stallMs ?? FLEET_DOWNLOAD_STALL_MS;
85
+ const maxResumes = options.maxResumes ?? FLEET_DOWNLOAD_MAX_RESUMES;
86
+ const name = path.basename(destination);
87
+ const part = `${destination}.part`;
88
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
89
+ let lastFailure = null;
90
+ for (let attempt = 0; attempt <= maxResumes; attempt += 1) {
91
+ const have = partFileSize(part);
92
+ const headers = { ...options.headers };
93
+ if (have > 0)
94
+ headers.Range = `bytes=${have}-`;
95
+ let response;
96
+ try {
97
+ response = await (Object.keys(headers).length > 0 ? fetcher(url, { headers }) : fetcher(url));
98
+ }
99
+ catch (error) {
100
+ lastFailure = error;
101
+ continue;
102
+ }
103
+ let offset = have;
104
+ let total;
105
+ if (response.status === 206 && have > 0) {
106
+ total = contentRangeTotal(response) ?? (contentLength(response) !== null ? have + contentLength(response) : null);
107
+ }
108
+ else if (response.status === 416 && have > 0) {
109
+ void response.body?.cancel().catch(() => undefined);
110
+ fs.renameSync(part, destination);
111
+ return destination;
112
+ }
113
+ else if (response.ok) {
114
+ offset = 0;
115
+ total = contentLength(response);
116
+ }
117
+ else {
118
+ void response.body?.cancel().catch(() => undefined);
119
+ throw expectedError(`Could not download ${name}: HQ storage returned HTTP ${response.status}.`);
120
+ }
121
+ const fd = fs.openSync(part, offset === 0 ? "w" : "r+", 0o600);
122
+ let received;
123
+ try {
124
+ received = await pumpBody(response, fd, offset, name, total, stallMs, options.onProgress);
125
+ }
126
+ catch (error) {
127
+ fs.closeSync(fd);
128
+ lastFailure = error;
129
+ continue;
130
+ }
131
+ fs.closeSync(fd);
132
+ if (total !== null && received !== total) {
133
+ if (received > total)
134
+ fs.rmSync(part, { force: true });
135
+ lastFailure = expectedError(`ended early at ${received} of ${total} bytes`);
136
+ continue;
137
+ }
138
+ fs.renameSync(part, destination);
139
+ return destination;
140
+ }
141
+ const cause = lastFailure instanceof Error ? lastFailure.message : String(lastFailure ?? "unknown error");
142
+ throw expectedError(`Could not download ${name} after ${maxResumes + 1} attempts (${cause}). Check your internet connection and retry; the download continues where it stopped.`);
143
+ }
144
+ /** sha256 of a file on disk, streamed. */
145
+ export function fileSha256(file) {
146
+ const hash = crypto.createHash("sha256");
147
+ const fd = fs.openSync(file, "r");
148
+ try {
149
+ const chunk = Buffer.allocUnsafe(1 << 20);
150
+ for (;;) {
151
+ const read = fs.readSync(fd, chunk, 0, chunk.length, null);
152
+ if (read === 0)
153
+ break;
154
+ hash.update(chunk.subarray(0, read));
155
+ }
156
+ }
157
+ finally {
158
+ fs.closeSync(fd);
159
+ }
160
+ return hash.digest("hex");
161
+ }
162
+ //# sourceMappingURL=fleet-download.js.map
@@ -65,6 +65,9 @@ export interface FleetLocalInstallDeps {
65
65
  now?: () => Date;
66
66
  release?: FleetLocalReleasePin;
67
67
  publicKey?: string | readonly string[];
68
+ onProgress?: FleetInstallProgressListener;
69
+ /** How long a download may go without bytes before it is resumed (tests). */
70
+ downloadStallMs?: number;
68
71
  }
69
72
  export interface FleetLocalProfileInput {
70
73
  agentUid: string;
@@ -113,6 +116,7 @@ export interface FleetLocalBootstrapDeps {
113
116
  spawn?: FleetLocalSpawn;
114
117
  start?: boolean;
115
118
  python?: FleetPython;
119
+ onProgress?: FleetInstallProgressListener;
116
120
  }
117
121
  interface SharedFleetRuntime {
118
122
  id: string;
@@ -257,9 +261,35 @@ export declare function installTargetFleetLocalDistribution(deps?: Omit<FleetLoc
257
261
  target?: FleetLocalTargetRelease;
258
262
  now?: () => number;
259
263
  }): Promise<FleetLocalInstallation>;
264
+ /** What an install is doing right now, for a progress bar or a log line. */
265
+ export type FleetInstallProgress = {
266
+ phase: "download";
267
+ file: string;
268
+ received: number;
269
+ total: number | null;
270
+ } | {
271
+ phase: "verify";
272
+ } | {
273
+ phase: "python";
274
+ } | {
275
+ phase: "extract";
276
+ } | {
277
+ phase: "environment";
278
+ } | {
279
+ phase: "packages";
280
+ } | {
281
+ phase: "ready";
282
+ step: "download" | "runtime";
283
+ cached: boolean;
284
+ };
285
+ export type FleetInstallProgressListener = (progress: FleetInstallProgress) => void;
260
286
  export declare const FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS = 30000;
261
- export declare const FLEET_LOCAL_ARCHIVE_DOWNLOAD_TIMEOUT_MS: number;
262
- export declare const FLEET_LOCAL_RUNTIME_DOWNLOAD_TIMEOUT_MS: number;
287
+ /** One machine-wide lock: concurrent creates and adopts share one download instead of repeating it. */
288
+ export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_NAME = "hq-fleet-local-distribution";
289
+ export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_WAIT_MS: number;
290
+ export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_STALE_MS: number;
291
+ /** Whether the pinned release is installed on this machine (no download needed). */
292
+ export declare function fleetLocalDistributionInstalled(release?: FleetLocalReleasePin, root?: string): boolean;
263
293
  export declare function installPinnedFleetLocalDistribution(deps?: FleetLocalInstallDeps): Promise<FleetLocalInstallation>;
264
294
  /** The process id the bot's gateway recorded, when its file reads; the caller checks whether it is alive. */
265
295
  export declare function fleetLocalGatewayPid(agentUid: string, deps?: Pick<FleetLocalControlDeps, "fleetHome">): number | null;
@@ -339,6 +369,25 @@ export declare function windowsExtractionPathProblem(destination: string, longes
339
369
  export declare function importFleetLocalMachineCredential(agentUid: string, machineCredentials: string, deps?: FleetLocalControlDeps, opts?: {
340
370
  keepSource?: boolean;
341
371
  }): void;
372
+ export interface FleetRuntimeInstallResult {
373
+ release: FleetLocalReleasePin;
374
+ distributionDir: string;
375
+ runtimeDir: string;
376
+ runtimeId: string;
377
+ }
378
+ /**
379
+ * Everything a bot needs that is shared by every bot on this machine: the
380
+ * pinned distribution and runtime archive, the private Python, and the built
381
+ * runtime environment. Safe to run any number of times; a complete install
382
+ * returns at once.
383
+ */
384
+ export declare function ensureFleetRuntimeInstalled(installation: FleetLocalInstallation, deps?: FleetLocalBootstrapDeps): Promise<FleetRuntimeInstallResult>;
385
+ /** Whether the shared runtime for this release is already built on this machine. */
386
+ export declare function fleetRuntimeReady(installation: Pick<FleetLocalInstallation, "release">, deps?: {
387
+ fleetHome?: string;
388
+ platform?: NodeJS.Platform;
389
+ python?: FleetPython;
390
+ }): boolean;
342
391
  export declare function bootstrapFleetLocalBot(input: FleetLocalProfileInput & {
343
392
  machineCredentials?: string;
344
393
  }, deps?: FleetLocalBootstrapDeps): Promise<FleetLocalBootstrapResult>;