@indigoai-us/hq-cli 5.345.200 → 5.345.201

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.
@@ -0,0 +1,276 @@
1
+ /** Owner-only source reports with durable at-least-once delivery receipts. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { readBotConfig } from "../config.js";
6
+ import { botsRoot } from "../paths.js";
7
+ import { emitBotEvent } from "../events.js";
8
+ import { isExecutiveBot } from "./meeting-rider.js";
9
+ import { signalLaneCompanies } from "./signal-to-lane.js";
10
+ import { readBuiltinJobsMarker, writeBuiltinJobsMarker } from "./hot-signal-sync.js";
11
+ import { readLocalJobsStrict, writeLocalJobs, withJobsLock } from "../local-jobs.js";
12
+ import { listLanesBySource, readAllLanes, retryUpdateLane } from "../../lanes/store.js";
13
+ import { buildSourceReport, renderSourceReportText } from "../../lanes/source-report.js";
14
+ import { lockFileContents, reclaimStaleLock } from "../../lanes/stale-lock.js";
15
+ import { isTerminalLaneState } from "../../lanes/types.js";
16
+ export const SOURCE_REPORT_JOB_KIND = "source-report";
17
+ export const REPORT_COOLDOWN_MS = 10 * 60 * 1000;
18
+ const events = ["meeting.ended", "lane.finished"];
19
+ const safeCompany = (value) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value) && value !== "personal";
20
+ export const sourceLaneTerminal = (lane) => isTerminalLaneState(lane.state);
21
+ export function ensureSourceReportJobs(dir, config, companies = signalLaneCompanies(config), home) {
22
+ if (!isExecutiveBot(config, home))
23
+ return [];
24
+ return withJobsLock(dir, () => {
25
+ const jobs = readLocalJobsStrict(dir);
26
+ const marker = readBuiltinJobsMarker(dir);
27
+ for (const company of new Set(companies.filter(safeCompany)))
28
+ for (const event of events) {
29
+ const key = `${SOURCE_REPORT_JOB_KIND}:${company}:${event}`;
30
+ if (marker.registered[key])
31
+ continue;
32
+ if (!jobs.some(job => job.context?.jobKind === SOURCE_REPORT_JOB_KIND && job.context.company === company && job.trigger?.event === event)) {
33
+ jobs.push({ jobId: `job_${randomUUID().replaceAll("-", "").slice(0, 20)}`,
34
+ prompt: "Deliver the source report to the owner in-process.", timezone: "UTC",
35
+ trigger: { kind: "event", event, filter: { company } },
36
+ context: { jobKind: SOURCE_REPORT_JOB_KIND, company }, createdAt: new Date().toISOString() });
37
+ }
38
+ marker.registered[key] = new Date().toISOString();
39
+ }
40
+ writeLocalJobs(dir, jobs);
41
+ writeBuiltinJobsMarker(dir, marker);
42
+ return jobs.filter(job => job.context?.jobKind === SOURCE_REPORT_JOB_KIND);
43
+ });
44
+ }
45
+ /** Local append only; duplicate watcher observations are harmless. */
46
+ export function emitExecutiveLaneFinished(hqRoot, lane, root = botsRoot(), home) {
47
+ if (!lane.signal || !lane.company || !sourceLaneTerminal(lane) || !fs.existsSync(root))
48
+ return false;
49
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
50
+ if (!entry.isDirectory())
51
+ continue;
52
+ const dir = path.join(root, entry.name);
53
+ const config = readBotConfig(dir);
54
+ if (!config || typeof config.hqRoot !== "string" || path.resolve(config.hqRoot) !== path.resolve(hqRoot) ||
55
+ !isExecutiveBot(config, home) || !signalLaneCompanies(config).includes(lane.company.slug))
56
+ continue;
57
+ const payload = { lane_id: lane.lane_id, signal_id: lane.signal.signal_id,
58
+ source_ref: lane.signal.source_ref, company: lane.company.slug, state: lane.state };
59
+ emitBotEvent(dir, "lane.finished", payload, new Date(), prior => prior.event === "lane.finished" && Object.entries(payload).every(([key, value]) => prior.payload[key] === value));
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ /** Replay terminal commits whose watcher never durably published its event. */
65
+ export function flushExecutiveLaneFinished(hqRoot, root = botsRoot(), home) {
66
+ const failures = [];
67
+ for (const lane of readAllLanes(hqRoot)) {
68
+ if (!lane.source_report_pending || !sourceLaneTerminal(lane))
69
+ continue;
70
+ try {
71
+ if (!emitExecutiveLaneFinished(hqRoot, lane, root, home))
72
+ continue;
73
+ // Clearing follows the journal append. A crash here only re-appends via
74
+ // the journal's dedupe; it cannot lose the terminal notification.
75
+ retryUpdateLane(hqRoot, lane.lane_id, "watcher", current => current.state === lane.state && current.company?.slug === lane.company?.slug &&
76
+ current.signal?.source_ref === lane.signal?.source_ref
77
+ ? { ...current, source_report_pending: undefined } : current);
78
+ }
79
+ catch (error) {
80
+ failures.push(error);
81
+ }
82
+ }
83
+ if (failures.length)
84
+ throw new AggregateError(failures, "Terminal report events remain pending");
85
+ }
86
+ export function sourceReportReceiptPath(hqRoot, company, sourceRef) {
87
+ if (!safeCompany(company))
88
+ throw new Error("Invalid report company");
89
+ return path.join(hqRoot, "workspace/executive/reports", company, `${createHash("sha256").update(sourceRef).digest("hex")}.json`);
90
+ }
91
+ function atomic(file, value) {
92
+ const tmp = `${file}.${randomUUID()}.tmp`;
93
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
94
+ fs.renameSync(tmp, file);
95
+ }
96
+ function validIntent(value) {
97
+ if (!value || typeof value !== "object")
98
+ return false;
99
+ const intent = value;
100
+ return typeof intent.body === "string" && typeof intent.allTerminal === "boolean" &&
101
+ (intent.phase === "meeting" || intent.phase === "final") && typeof intent.at === "string" &&
102
+ Number.isFinite(Date.parse(intent.at)) && (intent.rootEventId === undefined || typeof intent.rootEventId === "string");
103
+ }
104
+ function read(file) {
105
+ try {
106
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
107
+ if (!value || value.v !== 1 || !Array.isArray(value.sent) || typeof value.sourceRef !== "string" ||
108
+ typeof value.company !== "string" || typeof value.title !== "string" ||
109
+ typeof value.ownerUid !== "string" || typeof value.agentUid !== "string" || value.sent.length > 2 ||
110
+ (value.intent !== undefined && !validIntent(value.intent)) ||
111
+ (value.finalDueAt !== undefined && !Number.isFinite(Date.parse(value.finalDueAt))) ||
112
+ value.sent.some(sent => !validIntent(sent) || typeof sent.eventId !== "string" || !Number.isFinite(Date.parse(sent.sentAt)))) {
113
+ throw new Error("Invalid source report receipt");
114
+ }
115
+ return value;
116
+ }
117
+ catch (error) {
118
+ if (error.code === "ENOENT")
119
+ return undefined;
120
+ throw error;
121
+ }
122
+ }
123
+ /** Reclaim only a dead process's lock; never expire a live network send's lock. */
124
+ async function locked(file, work) {
125
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
126
+ const lock = `${file}.lock`;
127
+ const acquire = () => fs.writeFileSync(lock, lockFileContents(), { flag: "wx", mode: 0o600 });
128
+ try {
129
+ acquire();
130
+ }
131
+ catch (error) {
132
+ if (error.code !== "EEXIST")
133
+ throw error;
134
+ if (!reclaimStaleLock(lock, { staleMs: 60_000, liveOwnerMaxMs: Number.MAX_SAFE_INTEGER,
135
+ label: "source report" }))
136
+ throw new Error("Source report delivery is busy");
137
+ acquire();
138
+ }
139
+ const held = fs.statSync(lock);
140
+ try {
141
+ return await work();
142
+ }
143
+ finally {
144
+ try {
145
+ const current = fs.statSync(lock);
146
+ if (held.ino === current.ino && held.dev === current.dev)
147
+ fs.unlinkSync(lock);
148
+ }
149
+ catch (error) {
150
+ if (error.code !== "ENOENT")
151
+ process.stderr.write(`source report lock cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
152
+ }
153
+ }
154
+ }
155
+ export function createSourceReporter(deps) {
156
+ const { config } = deps;
157
+ const now = deps.now ?? (() => new Date());
158
+ const companies = deps.companies ?? (() => signalLaneCompanies(config));
159
+ const lanes = deps.lanes ?? ((company, ref) => listLanesBySource(company, ref, config.hqRoot));
160
+ let queue = Promise.resolve();
161
+ const serial = (work) => {
162
+ const result = queue.then(work);
163
+ queue = result.catch(() => undefined);
164
+ return result;
165
+ };
166
+ const allowed = (company) => isExecutiveBot(config, deps.home) && safeCompany(company) &&
167
+ companies().includes(company) && config.ownerUid.startsWith("prs_");
168
+ async function deliver(file, receipt) {
169
+ if (!receipt.intent)
170
+ return;
171
+ const intent = receipt.intent;
172
+ // At-least-once: a crash after sendDm accepts the DM but before this sent
173
+ // receipt is durable can resend on restart. A sent receipt always skips it.
174
+ const result = await deps.api.sendDm({ toPersonUid: config.ownerUid, body: intent.body,
175
+ audience: "human", ...(intent.rootEventId ? { rootEventId: intent.rootEventId } : {}) });
176
+ deps.afterSend?.();
177
+ if (!result.eventId)
178
+ throw new Error("Source report delivery returned no event receipt");
179
+ receipt.sent.push({ ...intent, eventId: result.eventId, sentAt: now().toISOString() });
180
+ delete receipt.intent;
181
+ atomic(file, receipt);
182
+ }
183
+ async function processSource(company, ref, event, title) {
184
+ if (!allowed(company))
185
+ return;
186
+ const file = sourceReportReceiptPath(config.hqRoot, company, ref);
187
+ await locked(file, async () => {
188
+ const receipt = read(file) ?? { v: 1, company, sourceRef: ref, ownerUid: config.ownerUid,
189
+ agentUid: config.agentUid, title: title ?? ref, sent: [] };
190
+ if (receipt.ownerUid !== config.ownerUid || receipt.agentUid !== config.agentUid || receipt.company !== company || receipt.sourceRef !== ref) {
191
+ throw new Error("Source report receipt identity mismatch");
192
+ }
193
+ await deliver(file, receipt);
194
+ if (receipt.sent.some(sent => sent.allTerminal) || receipt.sent.length >= 2)
195
+ return;
196
+ const current = lanes(company, ref).filter(lane => lane.company?.slug === company && lane.signal?.source_ref === ref);
197
+ const allTerminal = current.length > 0 && current.every(sourceLaneTerminal);
198
+ const meeting = event === "meeting.ended" && receipt.sent.length === 0;
199
+ if (!meeting && !allTerminal)
200
+ return;
201
+ if (!meeting && !event && !receipt.finalDueAt)
202
+ return;
203
+ const lastSent = receipt.sent.at(-1);
204
+ if (!meeting && lastSent && now().getTime() - Date.parse(lastSent.sentAt) < REPORT_COOLDOWN_MS) {
205
+ receipt.finalDueAt = new Date(Date.parse(lastSent.sentAt) + REPORT_COOLDOWN_MS).toISOString();
206
+ atomic(file, receipt);
207
+ return;
208
+ }
209
+ if (!meeting && receipt.finalDueAt && now().getTime() < Date.parse(receipt.finalDueAt))
210
+ return;
211
+ let template;
212
+ try {
213
+ template = fs.readFileSync(path.join(config.hqRoot, "personal/settings/executive/report.md"), "utf8");
214
+ }
215
+ catch (error) {
216
+ if (error.code !== "ENOENT")
217
+ throw error;
218
+ }
219
+ const body = renderSourceReportText(buildSourceReport(company, ref, current), template);
220
+ receipt.intent = { body: `Report for ${receipt.title}\n${body}`, allTerminal,
221
+ phase: meeting ? "meeting" : "final", at: now().toISOString(),
222
+ ...(lastSent ? { rootEventId: lastSent.eventId } : {}) };
223
+ delete receipt.finalDueAt;
224
+ atomic(file, receipt);
225
+ deps.afterIntent?.();
226
+ await deliver(file, receipt);
227
+ });
228
+ }
229
+ return {
230
+ handle(event, payload, company) {
231
+ return serial(async () => {
232
+ const p = payload;
233
+ if (typeof event !== "string" || !events.includes(event) ||
234
+ !p || typeof company !== "string" || p.company !== company || !allowed(company) ||
235
+ typeof p.source_ref !== "string" || !p.source_ref.trim()) {
236
+ return { status: "skipped-precondition", output: "Source outside the Executive report scope" };
237
+ }
238
+ await processSource(company, p.source_ref, event, typeof p.title === "string" ? p.title.replace(/[\r\n]+/g, " ") : undefined);
239
+ return { status: "succeeded", output: "Source report processed" };
240
+ });
241
+ },
242
+ /** Poll from the resident bot loop, so a last lane inside cooldown is not lost. */
243
+ recover() {
244
+ return serial(async () => {
245
+ if (!isExecutiveBot(config, deps.home))
246
+ return;
247
+ const failures = [];
248
+ try {
249
+ flushExecutiveLaneFinished(config.hqRoot, deps.botsDir, deps.home);
250
+ }
251
+ catch (error) {
252
+ failures.push(error);
253
+ }
254
+ for (const company of companies().filter(allowed)) {
255
+ const dir = path.dirname(sourceReportReceiptPath(config.hqRoot, company, ""));
256
+ if (!fs.existsSync(dir))
257
+ continue;
258
+ for (const name of fs.readdirSync(dir).filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
259
+ try {
260
+ const receipt = read(path.join(dir, name));
261
+ if (receipt && (receipt.intent || receipt.finalDueAt))
262
+ await processSource(company, receipt.sourceRef);
263
+ }
264
+ catch (error) {
265
+ failures.push(error);
266
+ }
267
+ }
268
+ }
269
+ // One damaged receipt must not starve unrelated pending reports.
270
+ if (failures.length)
271
+ throw new AggregateError(failures, "Source report recovery deferred");
272
+ });
273
+ },
274
+ };
275
+ }
276
+ //# sourceMappingURL=source-report.js.map
@@ -1,15 +1,118 @@
1
- import { type BotConfig } from "./config.js";
1
+ import { type BotConfig, type BotRuntimeId } from "./config.js";
2
2
  export declare const KEEP_LEGACY_RUNTIME_ENV = "HQ_BOT_KEEP_LEGACY_RUNTIME";
3
+ /** The runtimes whose Fleet sign-in can be imported from a login already on this computer. */
4
+ export type LegacyProviderRuntime = "codex" | "grok";
5
+ /**
6
+ * Whether the provider sign-in a legacy bot needs is readable on this
7
+ * computer, the way the HQ Fleet Runtime imports it: the Codex CLI login
8
+ * file, or another Fleet bot's Grok login. `env` is the extra environment the
9
+ * import runs with (a bot's own Codex home).
10
+ */
11
+ export interface LegacyProviderLogin {
12
+ readable: boolean;
13
+ /** Where the check looked, for the log, `hq bot status` and the owner's DM. */
14
+ detail: string;
15
+ env?: Record<string, string>;
16
+ }
17
+ export type LegacyProviderLoginProbe = (runtime: LegacyProviderRuntime) => LegacyProviderLogin;
18
+ export type LegacyConversionReason = "already-fleet" | "kept-by-env" | "needs-sign-in" | "own-claude-account";
3
19
  export type LegacyConversionDecision = {
4
20
  convert: true;
21
+ signIn?: {
22
+ runtime: LegacyProviderRuntime;
23
+ env?: Record<string, string>;
24
+ };
5
25
  } | {
6
26
  convert: false;
7
- reason: "already-fleet" | "kept-by-env" | "needs-sign-in" | "own-claude-account";
27
+ reason: LegacyConversionReason;
28
+ detail: string;
8
29
  };
9
- export declare function legacyConversionDecision(config: Pick<BotConfig, "engine" | "runtime" | "runtimeAuth">, env?: NodeJS.ProcessEnv): LegacyConversionDecision;
30
+ /**
31
+ * Whether a legacy bot moves to the HQ Fleet Runtime at this start.
32
+ *
33
+ * A Claude bot on the owner's login moves as is: the runtime reads the same
34
+ * Claude Code login. A Codex or Grok bot moves when its provider sign-in can
35
+ * be imported here without a browser (`probe`); otherwise it waits for
36
+ * `hq bot auth`. A Claude bot with its own Claude account stays: HQ Fleet
37
+ * Local answers with the owner's Claude Code login only, so that account
38
+ * cannot come along.
39
+ */
40
+ export declare function legacyConversionDecision(config: Pick<BotConfig, "engine" | "runtime" | "runtimeAuth">, env: NodeJS.ProcessEnv, probe: LegacyProviderLoginProbe): LegacyConversionDecision;
41
+ /** The `--provider` value `hq bot auth` takes for a runtime. */
42
+ export declare function fleetAuthProviderFlag(runtime: BotRuntimeId): string;
43
+ export interface LegacyHoldNotice {
44
+ /** Why the bot still runs on its legacy runner, in plain words. */
45
+ cause: string;
46
+ /** What the owner does about it. */
47
+ fix: string;
48
+ }
49
+ /** The one-time owner notice (and the status line) for a bot that stays on its legacy runner. */
50
+ export declare function legacyHoldNotice(name: string, runtime: BotRuntimeId, decision: Extract<LegacyConversionDecision, {
51
+ convert: false;
52
+ }>): LegacyHoldNotice;
53
+ /** Reasons the owner is told about once; the others are the owner's own choice or no hold at all. */
54
+ export declare function legacyHoldNotifiesOwner(reason: LegacyConversionReason): boolean;
10
55
  /** How long a legacy runner's own start skips the move after one failed. */
11
56
  export declare const FLEET_MOVE_RETRY_MS: number;
57
+ export interface FleetMoveFailure {
58
+ at: string;
59
+ /** The step that failed: install, sign in, start. */
60
+ step?: string;
61
+ error?: string;
62
+ }
12
63
  export declare function fleetMoveFailurePath(dir: string): string;
13
- export declare function writeFleetMoveFailure(dir: string, now?: Date): void;
64
+ export declare function writeFleetMoveFailure(dir: string, now?: Date, failure?: Omit<FleetMoveFailure, "at">): void;
65
+ export declare function readFleetMoveFailure(dir: string): FleetMoveFailure | null;
14
66
  export declare function fleetMoveRecentlyFailed(dir: string, now?: number): boolean;
67
+ /**
68
+ * Why the bot stayed on its legacy runner at its last start, and whether the
69
+ * owner has been told. One DM per reason: a bot that is still waiting for the
70
+ * same sign-in at every start does not DM the owner again.
71
+ */
72
+ export interface FleetMoveHold {
73
+ reason: LegacyConversionReason;
74
+ detail: string;
75
+ at: string;
76
+ notifiedAt?: string;
77
+ notifiedReason?: LegacyConversionReason;
78
+ /** The HQ failure report filed for this hold, once per reason. */
79
+ reportId?: string;
80
+ reportedReason?: LegacyConversionReason;
81
+ }
82
+ export declare function fleetMoveHoldPath(dir: string): string;
83
+ export declare function readFleetMoveHold(dir: string): FleetMoveHold | null;
84
+ export declare function writeFleetMoveHold(dir: string, hold: FleetMoveHold): void;
85
+ export declare function clearFleetMoveRecords(dir: string): void;
86
+ /** True when the owner has not yet been told about this reason. */
87
+ export declare function ownerHoldNoticeDue(hold: FleetMoveHold | null, reason: LegacyConversionReason): boolean;
88
+ /** True when no HQ failure report has been filed for this reason yet. */
89
+ export declare function holdReportDue(hold: FleetMoveHold | null, reason: LegacyConversionReason): boolean;
90
+ /**
91
+ * The failure report a held legacy bot files with HQ, in the shape the HQ
92
+ * Fleet Runtime's HQ DM adapter uploads for a failed turn (`build_upload`),
93
+ * so stuck legacy bots show up next to failing Fleet bots. No credential
94
+ * value is ever part of it.
95
+ */
96
+ export declare function legacyHoldFailureReport(input: {
97
+ reportId: string;
98
+ createdAt: string;
99
+ name: string;
100
+ config: Pick<BotConfig, "agentUid" | "ownerUid" | "runtime" | "model">;
101
+ decision: Extract<LegacyConversionDecision, {
102
+ convert: false;
103
+ }>;
104
+ notice: LegacyHoldNotice;
105
+ machine: string;
106
+ hqCliVersion: string;
107
+ os: string;
108
+ logPath: string;
109
+ }): Record<string, unknown>;
110
+ /** What `hq bot status` and `hq doctor` say about a legacy bot's move. */
111
+ export interface LegacyMoveState {
112
+ decision: LegacyConversionDecision;
113
+ notice: LegacyHoldNotice | null;
114
+ lastFailure: FleetMoveFailure | null;
115
+ hold: FleetMoveHold | null;
116
+ }
117
+ export declare function legacyMoveState(dir: string, config: Pick<BotConfig, "name" | "engine" | "runtime" | "runtimeAuth">, probe: LegacyProviderLoginProbe, env?: NodeJS.ProcessEnv): LegacyMoveState;
15
118
  //# sourceMappingURL=legacy-convert.d.ts.map
@@ -1,38 +1,184 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { HQ_FLEET_RUNTIME_ENGINE_ID } from "./config.js";
4
+ import { runtimeDisplayName } from "./runtime-sign-in.js";
4
5
  export const KEEP_LEGACY_RUNTIME_ENV = "HQ_BOT_KEEP_LEGACY_RUNTIME";
5
- export function legacyConversionDecision(config, env = process.env) {
6
+ /**
7
+ * Whether a legacy bot moves to the HQ Fleet Runtime at this start.
8
+ *
9
+ * A Claude bot on the owner's login moves as is: the runtime reads the same
10
+ * Claude Code login. A Codex or Grok bot moves when its provider sign-in can
11
+ * be imported here without a browser (`probe`); otherwise it waits for
12
+ * `hq bot auth`. A Claude bot with its own Claude account stays: HQ Fleet
13
+ * Local answers with the owner's Claude Code login only, so that account
14
+ * cannot come along.
15
+ */
16
+ export function legacyConversionDecision(config, env, probe) {
6
17
  if (config.engine?.id === HQ_FLEET_RUNTIME_ENGINE_ID)
7
- return { convert: false, reason: "already-fleet" };
18
+ return { convert: false, reason: "already-fleet", detail: "it already runs on the HQ Fleet Runtime" };
8
19
  if (env[KEEP_LEGACY_RUNTIME_ENV] === "1")
9
- return { convert: false, reason: "kept-by-env" };
10
- if (config.runtime !== "claude")
11
- return { convert: false, reason: "needs-sign-in" };
12
- if (config.runtimeAuth?.claude)
13
- return { convert: false, reason: "own-claude-account" };
14
- return { convert: true };
20
+ return { convert: false, reason: "kept-by-env", detail: `${KEEP_LEGACY_RUNTIME_ENV}=1 keeps it on its legacy runner` };
21
+ if (config.runtime === "claude") {
22
+ if (config.runtimeAuth?.claude !== undefined) {
23
+ return { convert: false, reason: "own-claude-account", detail: "it uses its own Claude account, which HQ Fleet Local cannot carry over" };
24
+ }
25
+ return { convert: true };
26
+ }
27
+ const runtime = config.runtime === "grok" ? "grok" : "codex";
28
+ const login = probe(runtime);
29
+ if (!login.readable)
30
+ return { convert: false, reason: "needs-sign-in", detail: login.detail };
31
+ return { convert: true, signIn: { runtime, ...(login.env ? { env: login.env } : {}) } };
32
+ }
33
+ /** The `--provider` value `hq bot auth` takes for a runtime. */
34
+ export function fleetAuthProviderFlag(runtime) {
35
+ return runtime === "grok" ? "grok" : runtime === "codex" ? "chatgpt" : "claude";
36
+ }
37
+ /** The one-time owner notice (and the status line) for a bot that stays on its legacy runner. */
38
+ export function legacyHoldNotice(name, runtime, decision) {
39
+ const tool = runtimeDisplayName(runtime);
40
+ const auth = `hq bot auth ${name} --provider ${fleetAuthProviderFlag(runtime)}`;
41
+ switch (decision.reason) {
42
+ case "needs-sign-in":
43
+ return {
44
+ cause: `${name} still runs on its older ${tool} runner, so it cannot report problems to HQ or get runtime fixes: its ${tool} sign-in is not readable on this computer (${decision.detail}).`,
45
+ fix: `Sign in to ${tool} with: ${auth}. ${name} then moves to the HQ Fleet Runtime.`,
46
+ };
47
+ case "own-claude-account":
48
+ return {
49
+ cause: `${name} still runs on its older Claude Code runner with its own Claude account, which HQ Fleet Local cannot carry over (it answers with your Claude Code login).`,
50
+ fix: `To move ${name} onto your Claude Code login, run: ${auth}.`,
51
+ };
52
+ case "kept-by-env":
53
+ return {
54
+ cause: `${name} stays on its older ${tool} runner because ${KEEP_LEGACY_RUNTIME_ENV}=1 is set.`,
55
+ fix: `Unset ${KEEP_LEGACY_RUNTIME_ENV} and start it again: hq bot start ${name}.`,
56
+ };
57
+ case "already-fleet":
58
+ return { cause: `${name} already runs on the HQ Fleet Runtime.`, fix: "" };
59
+ }
60
+ }
61
+ /** Reasons the owner is told about once; the others are the owner's own choice or no hold at all. */
62
+ export function legacyHoldNotifiesOwner(reason) {
63
+ return reason === "needs-sign-in" || reason === "own-claude-account";
15
64
  }
16
65
  /** How long a legacy runner's own start skips the move after one failed. */
17
66
  export const FLEET_MOVE_RETRY_MS = 60 * 60 * 1000;
18
67
  export function fleetMoveFailurePath(dir) {
19
68
  return path.join(dir, "fleet-move-failed.json");
20
69
  }
21
- export function writeFleetMoveFailure(dir, now = new Date()) {
70
+ export function writeFleetMoveFailure(dir, now = new Date(), failure = {}) {
22
71
  try {
23
- fs.writeFileSync(fleetMoveFailurePath(dir), JSON.stringify({ at: now.toISOString() }) + "\n", { mode: 0o600 });
72
+ fs.writeFileSync(fleetMoveFailurePath(dir), JSON.stringify({ at: now.toISOString(), ...failure }) + "\n", { mode: 0o600 });
24
73
  }
25
74
  catch {
26
75
  /* the next start just tries again */
27
76
  }
28
77
  }
29
- export function fleetMoveRecentlyFailed(dir, now = Date.now()) {
78
+ export function readFleetMoveFailure(dir) {
30
79
  try {
31
- const at = Date.parse(JSON.parse(fs.readFileSync(fleetMoveFailurePath(dir), "utf8")).at ?? "");
32
- return Number.isFinite(at) && now - at < FLEET_MOVE_RETRY_MS;
80
+ const raw = JSON.parse(fs.readFileSync(fleetMoveFailurePath(dir), "utf8"));
81
+ if (typeof raw.at !== "string" || !Number.isFinite(Date.parse(raw.at)))
82
+ return null;
83
+ return {
84
+ at: raw.at,
85
+ ...(typeof raw.step === "string" ? { step: raw.step } : {}),
86
+ ...(typeof raw.error === "string" ? { error: raw.error } : {}),
87
+ };
33
88
  }
34
89
  catch {
90
+ return null;
91
+ }
92
+ }
93
+ export function fleetMoveRecentlyFailed(dir, now = Date.now()) {
94
+ const failure = readFleetMoveFailure(dir);
95
+ if (!failure)
35
96
  return false;
97
+ const at = Date.parse(failure.at);
98
+ return Number.isFinite(at) && now - at < FLEET_MOVE_RETRY_MS;
99
+ }
100
+ export function fleetMoveHoldPath(dir) {
101
+ return path.join(dir, "fleet-move-hold.json");
102
+ }
103
+ export function readFleetMoveHold(dir) {
104
+ try {
105
+ const raw = JSON.parse(fs.readFileSync(fleetMoveHoldPath(dir), "utf8"));
106
+ if (typeof raw.reason !== "string" || typeof raw.at !== "string")
107
+ return null;
108
+ return {
109
+ reason: raw.reason,
110
+ detail: typeof raw.detail === "string" ? raw.detail : "",
111
+ at: raw.at,
112
+ ...(typeof raw.notifiedAt === "string" ? { notifiedAt: raw.notifiedAt } : {}),
113
+ ...(typeof raw.notifiedReason === "string" ? { notifiedReason: raw.notifiedReason } : {}),
114
+ ...(typeof raw.reportId === "string" ? { reportId: raw.reportId } : {}),
115
+ ...(typeof raw.reportedReason === "string" ? { reportedReason: raw.reportedReason } : {}),
116
+ };
36
117
  }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ export function writeFleetMoveHold(dir, hold) {
123
+ try {
124
+ fs.writeFileSync(fleetMoveHoldPath(dir), JSON.stringify(hold) + "\n", { mode: 0o600 });
125
+ }
126
+ catch {
127
+ /* status then shows the live decision without the record */
128
+ }
129
+ }
130
+ export function clearFleetMoveRecords(dir) {
131
+ fs.rmSync(fleetMoveFailurePath(dir), { force: true });
132
+ fs.rmSync(fleetMoveHoldPath(dir), { force: true });
133
+ }
134
+ /** True when the owner has not yet been told about this reason. */
135
+ export function ownerHoldNoticeDue(hold, reason) {
136
+ return hold?.notifiedReason !== reason;
137
+ }
138
+ /** True when no HQ failure report has been filed for this reason yet. */
139
+ export function holdReportDue(hold, reason) {
140
+ return hold?.reportedReason !== reason;
141
+ }
142
+ /**
143
+ * The failure report a held legacy bot files with HQ, in the shape the HQ
144
+ * Fleet Runtime's HQ DM adapter uploads for a failed turn (`build_upload`),
145
+ * so stuck legacy bots show up next to failing Fleet bots. No credential
146
+ * value is ever part of it.
147
+ */
148
+ export function legacyHoldFailureReport(input) {
149
+ return {
150
+ reportId: input.reportId,
151
+ createdAt: input.createdAt,
152
+ agentUid: input.config.agentUid,
153
+ ownerUid: input.config.ownerUid,
154
+ botName: input.name,
155
+ machine: input.machine,
156
+ failureClass: "auth",
157
+ site: "legacy-fleet-move",
158
+ provider: input.config.runtime,
159
+ model: input.config.model ?? null,
160
+ platform: "hq-cli-legacy-runner",
161
+ conversationKey: null,
162
+ detail: `${input.decision.reason}: ${input.decision.detail}`,
163
+ leakLabels: [],
164
+ credentialSources: [],
165
+ runtimeVersion: "",
166
+ distributionVersion: "",
167
+ hqCliVersion: input.hqCliVersion,
168
+ os: input.os,
169
+ pythonVersion: "",
170
+ errorsLogPath: input.logPath,
171
+ errorsLogTail: "",
172
+ ownerNotice: { cause: input.notice.cause, fix: input.notice.fix },
173
+ };
174
+ }
175
+ export function legacyMoveState(dir, config, probe, env = process.env) {
176
+ const decision = legacyConversionDecision(config, env, probe);
177
+ return {
178
+ decision,
179
+ notice: decision.convert ? null : legacyHoldNotice(config.name, config.runtime, decision),
180
+ lastFailure: readFleetMoveFailure(dir),
181
+ hold: readFleetMoveHold(dir),
182
+ };
37
183
  }
38
184
  //# sourceMappingURL=legacy-convert.js.map
@@ -0,0 +1,34 @@
1
+ import type { BotConfig } from "./config.js";
2
+ import { type FleetLocalProviderAuthStatus } from "./fleet-local.js";
3
+ import type { LegacyProviderLogin, LegacyProviderLoginProbe } from "./legacy-convert.js";
4
+ export declare function codexCliAuthPath(home: string): string;
5
+ /** The `exp` claim of a JWT, in seconds, or null when the token has none that reads. */
6
+ export declare function jwtExpiry(token: string): number | null;
7
+ /** Mirrors the runtime's `_import_codex_cli_tokens`: a refreshable, unexpired login in `<home>/auth.json`. */
8
+ export declare function codexCliLoginReadable(home: string, now?: number): LegacyProviderLogin;
9
+ /**
10
+ * Where a legacy bot's Codex login lives: its own folder, a capacity account,
11
+ * or the owner's (CODEX_HOME, else ~/.codex). `env` is set when the import
12
+ * must look somewhere other than the owner's default.
13
+ */
14
+ export declare function legacyCodexHome(dir: string, config: Pick<BotConfig, "name" | "runtime" | "runtimeAuth" | "hqRoot">, env?: NodeJS.ProcessEnv, homedir?: string): {
15
+ home: string;
16
+ env?: Record<string, string>;
17
+ } | {
18
+ error: string;
19
+ };
20
+ export interface GrokLoginProbeDeps {
21
+ profiles?: () => Array<{
22
+ agentUid: string;
23
+ profileDir: string;
24
+ }>;
25
+ status?: (agentUid: string) => FleetLocalProviderAuthStatus | null;
26
+ }
27
+ /** The helper asks the keychain per profile, so one list of bots shares a scan this long. */
28
+ export declare const GROK_HOLDER_SCAN_TTL_MS = 30000;
29
+ export declare function resetGrokHolderScan(): void;
30
+ /** Mirrors the helper's `_existing_machine_oauth`: another Fleet bot here holds a Grok OAuth login. */
31
+ export declare function grokLoginReadableHere(exclude: string, deps?: GrokLoginProbeDeps, now?: number): LegacyProviderLogin;
32
+ /** The live probe for a bot: Codex from its login home, Grok from the other Fleet bots here. */
33
+ export declare function legacyProviderLoginProbe(dir: string, config: Pick<BotConfig, "name" | "agentUid" | "runtime" | "runtimeAuth" | "hqRoot">): LegacyProviderLoginProbe;
34
+ //# sourceMappingURL=legacy-provider-login.d.ts.map