@indigoai-us/hq-cli 5.345.86 → 5.345.87

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.
@@ -51,6 +51,8 @@ export declare function coreHasFlag(script: string, flag: string): boolean;
51
51
  * ref names are taken from it and the line itself (which tells the user to
52
52
  * delete them) is dropped.
53
53
  */
54
+ /** Plain fallback when core's prune refusal carried no message. */
55
+ export declare function pruneNothingToFreeMessage(n: number): string;
54
56
  export declare function parseRefusal(stdout: string, stderr: string): LocalReport;
55
57
  /** Core exit code for a failed precondition (remote-tracking branches, ...). */
56
58
  export declare const CORE_PRECONDITION_EXIT = 5;
@@ -70,14 +70,22 @@ function normalizeHoldingRefs(raw) {
70
70
  * ref names are taken from it and the line itself (which tells the user to
71
71
  * delete them) is dropped.
72
72
  */
73
+ /** Plain fallback when core's prune refusal carried no message. */
74
+ export function pruneNothingToFreeMessage(n) {
75
+ return (`Pruning would free nothing: ${n} ${n === 1 ? "branch, saved version or worktree still uses" : "branches, saved versions or worktrees still use"} ` +
76
+ "this history. Nothing was changed.");
77
+ }
73
78
  export function parseRefusal(stdout, stderr) {
74
79
  let refs = [];
75
80
  let count;
81
+ let message;
76
82
  try {
77
83
  const parsed = JSON.parse(stdout);
78
84
  refs = normalizeHoldingRefs(parsed.holding_refs);
79
85
  if (typeof parsed.holding_refs_count === "number")
80
86
  count = parsed.holding_refs_count;
87
+ if (typeof parsed.message === "string" && parsed.message.trim())
88
+ message = parsed.message;
81
89
  }
82
90
  catch {
83
91
  const m = /\(([^()]*refs\/[^()]*)\)/.exec(stderr);
@@ -88,11 +96,14 @@ export function parseRefusal(stdout, stderr) {
88
96
  .map((name) => ({ name, commit_date: null }));
89
97
  }
90
98
  }
99
+ const n = count ?? refs.length;
91
100
  return {
92
101
  available: true,
93
102
  refused: "nothing_to_free",
103
+ // Same shape as offload's refusal: a plain `message` beside the code.
104
+ message: message ?? pruneNothingToFreeMessage(n),
94
105
  holding_refs: refs,
95
- holding_refs_count: count ?? refs.length,
106
+ holding_refs_count: n,
96
107
  };
97
108
  }
98
109
  /** Core exit code for a failed precondition (remote-tracking branches, ...). */
@@ -261,6 +261,16 @@ export interface OffloadResult {
261
261
  */
262
262
  refused?: string;
263
263
  message?: string;
264
+ /**
265
+ * Dry run only: the refusal a real run would hit, or null when none. A
266
+ * dry run that predicts a refusal still exits 0; only a real run that
267
+ * refuses exits 4 or 5.
268
+ */
269
+ would_refuse?: {
270
+ code: string;
271
+ message: string;
272
+ holders: string[];
273
+ } | null;
264
274
  };
265
275
  current: {
266
276
  offloaded: {
@@ -495,6 +495,7 @@ export async function runOffload(opts, deps) {
495
495
  result.history.message = refusal.message;
496
496
  if (!opts.dryRun)
497
497
  return result;
498
+ result.history.would_refuse = { code: refusal.refused, message: refusal.message, holders: [] };
498
499
  }
499
500
  else if (blobs.length === 0 && held.length > 0) {
500
501
  // Same contract as prune: nothing to free is a refusal (exit 4), decided
@@ -503,9 +504,12 @@ export async function runOffload(opts, deps) {
503
504
  result.history.message = worktreeHoldsAllCopy(held);
504
505
  if (!opts.dryRun)
505
506
  return result;
507
+ result.history.would_refuse = { code: "nothing_to_free", message: result.history.message, holders: heldWorktrees(held) };
506
508
  }
507
509
  const noBackup = coreHasFlag(script, "--no-backup");
508
510
  if (opts.dryRun) {
511
+ if (result.history.would_refuse === undefined)
512
+ result.history.would_refuse = null;
509
513
  // Dry run fills the real-run fields with estimates so a preview reads the
510
514
  // same keys: every candidate uploaded, and history shrinks by its size.
511
515
  const planned = refusal ? 0 : result.history.candidate_bytes;
@@ -213,6 +213,12 @@ export declare function holderName(ref: string): string;
213
213
  export declare function formatHoldingRefusal(local: LocalReport): string[];
214
214
  /** Exit code for a prune result: 4 for nothing to free, 5 for a precondition, else 0/1. */
215
215
  export declare function pruneExitCode(result: StoragePruneResult): number;
216
+ /**
217
+ * Exit code for an offload result: a real run that refuses exits 4 (nothing
218
+ * to free) or 5 (precondition); a dry run that predicts a refusal exits 0 and
219
+ * reports it in `history.would_refuse`. Errors exit 1.
220
+ */
221
+ export declare function offloadExitCode(result: OffloadResult): number;
216
222
  /** Plain lines for a local prune that was not refused (dry or real). */
217
223
  export declare function formatLocalPrune(local: LocalReport, dryRun: boolean): string[];
218
224
  export declare function formatStoragePrune(result: StoragePruneResult): string;
@@ -743,9 +743,15 @@ export function formatOffload(r) {
743
743
  const lines = [];
744
744
  const h = r.history;
745
745
  if (h.refused) {
746
+ // Core's and the CLI's refusal messages may already say nothing changed;
747
+ // add that sentence only when the message lacks it.
748
+ const msg = (h.message ?? "").trim();
749
+ const lead = /^nothing was uploaded or changed\.\s*/i;
746
750
  lines.push(r.dry_run
747
- ? `History: a real run would stop before uploading: ${h.message ?? ""}`.trimEnd()
748
- : `History: nothing was uploaded or changed. ${h.message ?? ""}`.trimEnd());
751
+ ? `History: a real run would stop before uploading: ${msg.replace(lead, "")}`.trimEnd()
752
+ : lead.test(msg)
753
+ ? `History: ${msg}`
754
+ : `History: nothing was uploaded or changed. ${msg}`.trimEnd());
749
755
  }
750
756
  else if (h.available === false) {
751
757
  lines.push(`History: not available - ${h.reason ?? "unknown"}`);
@@ -812,6 +818,10 @@ export function formatHoldingRefusal(local) {
812
818
  /** Exit code for a prune result: 4 for nothing to free, 5 for a precondition, else 0/1. */
813
819
  export function pruneExitCode(result) {
814
820
  const local = result.local;
821
+ // A dry run that ran is a success even when it predicts a refusal; the
822
+ // JSON reports that in `would_refuse` / `local.refused`.
823
+ if (result.dry_run)
824
+ return result.cloud.some((c) => c.errors.length > 0) ? 1 : 0;
815
825
  if (local?.refused === "nothing_to_free")
816
826
  return 4;
817
827
  if (local && typeof local.refused === "string")
@@ -820,6 +830,16 @@ export function pruneExitCode(result) {
820
830
  return 1;
821
831
  return 0;
822
832
  }
833
+ /**
834
+ * Exit code for an offload result: a real run that refuses exits 4 (nothing
835
+ * to free) or 5 (precondition); a dry run that predicts a refusal exits 0 and
836
+ * reports it in `history.would_refuse`. Errors exit 1.
837
+ */
838
+ export function offloadExitCode(result) {
839
+ if (result.history.refused && !result.dry_run)
840
+ return result.history.refused === "nothing_to_free" ? 4 : 5;
841
+ return result.errors.length > 0 ? 1 : 0;
842
+ }
823
843
  /** Plain lines for a local prune that was not refused (dry or real). */
824
844
  export function formatLocalPrune(local, dryRun) {
825
845
  const w = local.would_refuse && typeof local.would_refuse === "object" ? local.would_refuse : null;
@@ -1126,10 +1146,9 @@ export function registerStorageCommand(program) {
1126
1146
  if (!result)
1127
1147
  return;
1128
1148
  console.log(opts.json ? JSON.stringify(result, null, 2) : formatOffload(result));
1129
- if (result.history.refused)
1130
- process.exitCode = result.history.refused === "nothing_to_free" ? 4 : 5;
1131
- else if (result.errors.length > 0)
1132
- process.exitCode = 1;
1149
+ const code = offloadExitCode(result);
1150
+ if (code !== 0)
1151
+ process.exitCode = code;
1133
1152
  }
1134
1153
  catch (err) {
1135
1154
  console.error(chalk.red(err.message));
@@ -117,6 +117,8 @@ export interface AgentRecord {
117
117
  at?: string;
118
118
  } | null;
119
119
  };
120
+ /** "assistant" on HQ's setup assistant, null on other local bots; absent on older servers. */
121
+ agentRole?: string | null;
120
122
  }
121
123
  /**
122
124
  * One of the caller's own local bots, as `GET /v1/agents/mine?local=1` lists
@@ -133,6 +135,8 @@ export interface MyLocalBot {
133
135
  online?: boolean;
134
136
  lastHeartbeatAt?: string | null;
135
137
  localConfig?: unknown;
138
+ /** "assistant" on HQ's setup assistant, null on other local bots; absent on older servers. */
139
+ agentRole?: string | null;
136
140
  }
137
141
  /** What the credential re-issue route hands back (a rotated machine secret). */
138
142
  export interface ReissuedBotCredentials {
@@ -349,6 +353,17 @@ export declare class BotApi {
349
353
  listMine(opts?: {
350
354
  local?: boolean;
351
355
  }): Promise<MyLocalBot[]>;
356
+ /**
357
+ * PUT /v1/agents/{uid}/display-name, owner-only: the one write for a bot's
358
+ * name. The server sets the entity name, the profile display name and the
359
+ * saved local settings' display name together, and answers 200 for the
360
+ * same name twice. A server that predates the route answers 404 or 405;
361
+ * callers decide what that means (see writeCloudDisplayName).
362
+ */
363
+ setDisplayName(agentUid: string, displayName: string): Promise<{
364
+ agentUid: string;
365
+ displayName: string;
366
+ }>;
352
367
  /** DELETE /v1/agents/{uid} — owner tears the identity down. */
353
368
  deleteLocalBot(agentUid: string): Promise<{
354
369
  uid: string;
@@ -311,6 +311,17 @@ export class BotApi {
311
311
  });
312
312
  return Array.isArray(body.agents) ? body.agents : [];
313
313
  }
314
+ /**
315
+ * PUT /v1/agents/{uid}/display-name, owner-only: the one write for a bot's
316
+ * name. The server sets the entity name, the profile display name and the
317
+ * saved local settings' display name together, and answers 200 for the
318
+ * same name twice. A server that predates the route answers 404 or 405;
319
+ * callers decide what that means (see writeCloudDisplayName).
320
+ */
321
+ async setDisplayName(agentUid, displayName) {
322
+ const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/display-name`, { method: "PUT", body: { displayName } }, [200]);
323
+ return { agentUid: body.agentUid ?? agentUid, displayName: body.displayName ?? displayName };
324
+ }
314
325
  /** DELETE /v1/agents/{uid} — owner tears the identity down. */
315
326
  async deleteLocalBot(agentUid) {
316
327
  const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}`, { method: "DELETE" });
@@ -124,6 +124,12 @@ export interface BotConfig {
124
124
  * create; `name` stays the handle every hq bot command uses. Unset = `name`.
125
125
  */
126
126
  displayName?: string;
127
+ /**
128
+ * True while `displayName` is newer than HQ's copy: the last rename was
129
+ * kept on this computer because the server had no name route yet. Until a
130
+ * rename reaches HQ, this copy wins over the cloud's older name.
131
+ */
132
+ displayNameLocalOnly?: boolean;
127
133
  /**
128
134
  * Verbatim hello the bot DMs its owner when it first comes online (and on
129
135
  * `hq bot intro`). Unset = the default introduction.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A bot's name and role, as the CLI reports them to the desktop app.
3
+ *
4
+ * The name people see lives in the cloud (entity name, profile display name
5
+ * and the saved local settings, written together by
6
+ * `PUT /v1/agents/{uid}/display-name`). bot.json keeps a copy so the running
7
+ * bot can introduce itself. A handle (`setup`) or a slug (`setup-1a2b3c4d`)
8
+ * is never a name, so it is never reported as one.
9
+ *
10
+ * The role marks HQ's setup assistant, so clients find it by role or agent
11
+ * uid and never by its name or handle.
12
+ */
13
+ import { type BotApi, type MyLocalBot } from "./api.js";
14
+ import { type BotConfig } from "./config.js";
15
+ export declare const ASSISTANT_ROLE: "assistant";
16
+ export type BotRole = typeof ASSISTANT_ROLE | null;
17
+ /** The setup assistant's cloud slug: the handle plus the owner's suffix. */
18
+ export declare function setupSlugFor(ownerUid: string): string;
19
+ /**
20
+ * A cloud name worth showing a person, or null when it is empty or is just
21
+ * the bot's handle or slug (bots created before names existed carry those).
22
+ */
23
+ export declare function personFacingName(value: string | null | undefined, handles: ReadonlyArray<string | null | undefined>): string | null;
24
+ /** The role of a bot set up on this computer. */
25
+ export declare function localBotRole(config: Pick<BotConfig, "name" | "workerId">): BotRole;
26
+ /**
27
+ * The role of a bot as the cloud lists it: the server's `agentRole` marker,
28
+ * else (an older server) the reserved setup slug and the setup worker.
29
+ */
30
+ export declare function remoteBotRole(bot: MyLocalBot): BotRole;
31
+ /** The cloud name of a listed bot, never its handle or slug. */
32
+ export declare function remoteBotDisplayName(bot: MyLocalBot, handle: string | null): string | null;
33
+ /**
34
+ * Where a name write ended up. "cloud": the server stored it. "local-only":
35
+ * the server does not have the name route yet (404/405), so only bot.json
36
+ * has it until the server is updated.
37
+ */
38
+ export type NameSaved = "cloud" | "local-only";
39
+ export interface CloudNameWriteResult {
40
+ saved: NameSaved;
41
+ /** Why the write stayed local, for the log; never shown to a person. */
42
+ detail?: string;
43
+ }
44
+ /**
45
+ * A 405, or a 404 from API Gateway itself, means this server has no name
46
+ * route yet. The route's own 404 (unknown agent) answers `{ error: "Not found" }`
47
+ * or carries a code, and is not a missing route: the caller has to hear that
48
+ * HQ does not know the bot.
49
+ */
50
+ export declare function isNameRouteMissing(err: unknown): boolean;
51
+ /** The route's own 404: HQ has no such agent (deleted, or a stale bot.json). */
52
+ export declare function isUnknownAgent(err: unknown): boolean;
53
+ /**
54
+ * Write a bot's name to the cloud. A server without the route is not a
55
+ * failure: the caller keeps the name on this computer and the result says so.
56
+ * Any other error is thrown for the caller to handle.
57
+ */
58
+ export declare function writeCloudDisplayName(api: Pick<BotApi, "setDisplayName">, agentUid: string, displayName: string, opts?: {
59
+ attempts?: number;
60
+ retryDelayMs?: number;
61
+ }): Promise<CloudNameWriteResult>;
62
+ /** 409 DISPLAY_NAME_WRITE_CONFLICT: the record changed during the server's own retries. */
63
+ export declare function isNameWriteConflict(err: unknown): boolean;
64
+ //# sourceMappingURL=display-name.d.ts.map
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A bot's name and role, as the CLI reports them to the desktop app.
3
+ *
4
+ * The name people see lives in the cloud (entity name, profile display name
5
+ * and the saved local settings, written together by
6
+ * `PUT /v1/agents/{uid}/display-name`). bot.json keeps a copy so the running
7
+ * bot can introduce itself. A handle (`setup`) or a slug (`setup-1a2b3c4d`)
8
+ * is never a name, so it is never reported as one.
9
+ *
10
+ * The role marks HQ's setup assistant, so clients find it by role or agent
11
+ * uid and never by its name or handle.
12
+ */
13
+ import { BotApiError } from "./api.js";
14
+ import { SETUP_BOT_WORKER_ID } from "./config.js";
15
+ import { parseBotLocalConfig } from "./local-config.js";
16
+ export const ASSISTANT_ROLE = "assistant";
17
+ /** The handle the desktop app creates HQ's setup assistant under. */
18
+ const SETUP_HANDLE = "setup";
19
+ /** The setup assistant's cloud slug: the handle plus the owner's suffix. */
20
+ export function setupSlugFor(ownerUid) {
21
+ return `${SETUP_HANDLE}-${ownerUid.trim().slice(-8).toLowerCase()}`;
22
+ }
23
+ /**
24
+ * A cloud name worth showing a person, or null when it is empty or is just
25
+ * the bot's handle or slug (bots created before names existed carry those).
26
+ */
27
+ export function personFacingName(value, handles) {
28
+ const name = (value ?? "").trim();
29
+ if (!name)
30
+ return null;
31
+ const lower = name.toLowerCase();
32
+ for (const h of handles) {
33
+ if (h && h.trim().toLowerCase() === lower)
34
+ return null;
35
+ }
36
+ return name;
37
+ }
38
+ /** The role of a bot set up on this computer. */
39
+ export function localBotRole(config) {
40
+ return config.name === SETUP_HANDLE && config.workerId === SETUP_BOT_WORKER_ID ? ASSISTANT_ROLE : null;
41
+ }
42
+ /**
43
+ * The role of a bot as the cloud lists it: the server's `agentRole` marker,
44
+ * else (an older server) the reserved setup slug and the setup worker.
45
+ */
46
+ export function remoteBotRole(bot) {
47
+ if (bot.agentRole === ASSISTANT_ROLE)
48
+ return ASSISTANT_ROLE;
49
+ const local = parseBotLocalConfig(bot.localConfig);
50
+ const owner = (bot.ownerUid ?? "").trim();
51
+ if (owner &&
52
+ (bot.slug ?? "").trim().toLowerCase() === setupSlugFor(owner) &&
53
+ local?.workerSource === "worker" &&
54
+ local.workerId === SETUP_BOT_WORKER_ID) {
55
+ return ASSISTANT_ROLE;
56
+ }
57
+ return null;
58
+ }
59
+ /** The cloud name of a listed bot, never its handle or slug. */
60
+ export function remoteBotDisplayName(bot, handle) {
61
+ const raw = bot;
62
+ const handles = [handle, bot.slug, bot.agentUid];
63
+ if (typeof raw.displayName === "string") {
64
+ const shown = personFacingName(raw.displayName, handles);
65
+ if (shown)
66
+ return shown;
67
+ }
68
+ return personFacingName(bot.name, handles) ?? personFacingName(parseBotLocalConfig(bot.localConfig)?.displayName, handles);
69
+ }
70
+ /**
71
+ * A 405, or a 404 from API Gateway itself, means this server has no name
72
+ * route yet. The route's own 404 (unknown agent) answers `{ error: "Not found" }`
73
+ * or carries a code, and is not a missing route: the caller has to hear that
74
+ * HQ does not know the bot.
75
+ */
76
+ export function isNameRouteMissing(err) {
77
+ if (!(err instanceof BotApiError))
78
+ return false;
79
+ if (err.status === 405)
80
+ return true;
81
+ return err.status === 404 && !err.code && err.detail !== "Not found";
82
+ }
83
+ /** The route's own 404: HQ has no such agent (deleted, or a stale bot.json). */
84
+ export function isUnknownAgent(err) {
85
+ return err instanceof BotApiError && err.status === 404 && !isNameRouteMissing(err);
86
+ }
87
+ /**
88
+ * Write a bot's name to the cloud. A server without the route is not a
89
+ * failure: the caller keeps the name on this computer and the result says so.
90
+ * Any other error is thrown for the caller to handle.
91
+ */
92
+ export async function writeCloudDisplayName(api, agentUid, displayName, opts = {}) {
93
+ const attempts = Math.max(1, opts.attempts ?? 3);
94
+ for (let attempt = 1;; attempt++) {
95
+ try {
96
+ await api.setDisplayName(agentUid, displayName);
97
+ return { saved: "cloud" };
98
+ }
99
+ catch (err) {
100
+ if (isNameRouteMissing(err)) {
101
+ return { saved: "local-only", detail: `the server has no name route yet (${err.status})` };
102
+ }
103
+ // The server gave up because the record kept changing under it; the
104
+ // write is safe to repeat.
105
+ if (isNameWriteConflict(err) && attempt < attempts) {
106
+ await new Promise((r) => setTimeout(r, (opts.retryDelayMs ?? 250) * attempt));
107
+ continue;
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+ }
113
+ /** 409 DISPLAY_NAME_WRITE_CONFLICT: the record changed during the server's own retries. */
114
+ export function isNameWriteConflict(err) {
115
+ return err instanceof BotApiError && err.status === 409 && err.code === "DISPLAY_NAME_WRITE_CONFLICT";
116
+ }
117
+ //# sourceMappingURL=display-name.js.map
@@ -20,4 +20,6 @@ export * from "./local-name.js";
20
20
  export * from "./job-run.js";
21
21
  export * from "./job-completions.js";
22
22
  export * from "./inflight.js";
23
+ export * from "./lock.js";
24
+ export * from "./display-name.js";
23
25
  //# sourceMappingURL=index.d.ts.map
@@ -20,4 +20,6 @@ export * from "./local-name.js";
20
20
  export * from "./job-run.js";
21
21
  export * from "./job-completions.js";
22
22
  export * from "./inflight.js";
23
+ export * from "./lock.js";
24
+ export * from "./display-name.js";
23
25
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * One writer per bot at a time.
3
+ *
4
+ * `hq bot create`, `adopt`, `restore`, `rm` and `set` all change the same
5
+ * bot's folder, and an adopt also rotates the bot's machine secret. Two of
6
+ * them running at once for the same bot (the desktop's automatic restore and
7
+ * its first-run create, a minute after sign-in) used to race on a
8
+ * check-then-write of bot.json: the loser either threw "already set up on this
9
+ * computer" or, worse, wrote a credential the winner had already rotated away.
10
+ *
11
+ * The lock is a file under the bots folder, `~/.hq/bots/.locks/<name>.lock`,
12
+ * created with O_CREAT|O_EXCL. It sits beside the bot's folder rather than in
13
+ * it: a folder that exists only to hold a lock would read as "a different bot
14
+ * already has this name" to the code that picks a restored bot's folder, and
15
+ * as "something is left here" to `hq bot rm`. Its body names the holder (pid,
16
+ * host, start time, a random token), so:
17
+ * - a holder whose process is gone, or that has held it for longer than
18
+ * {@link BOT_LOCK_STALE_MS}, is reclaimed;
19
+ * - release only removes the lock it took (the token must match), so a
20
+ * reclaim by another process never makes this process remove someone
21
+ * else's lock.
22
+ *
23
+ * The lock is re-entrant within one async call chain (AsyncLocalStorage):
24
+ * create holds it and calls the shared adopt path, which takes it again
25
+ * without waiting on itself. Two separate chains in one process still
26
+ * serialize, exactly like two processes.
27
+ */
28
+ /** Not a valid bot name (it starts with a dot), so no bot can collide with it. */
29
+ export declare const BOT_LOCKS_DIRNAME = ".locks";
30
+ /** A holder older than this is treated as crashed, whatever its pid says. */
31
+ export declare const BOT_LOCK_STALE_MS: number;
32
+ /** How long a second writer waits for the first before giving up. */
33
+ export declare const BOT_LOCK_WAIT_MS: number;
34
+ export interface BotLockHolder {
35
+ pid: number;
36
+ host: string;
37
+ startedAt: string;
38
+ token: string;
39
+ command: string;
40
+ }
41
+ export interface BotLockOptions {
42
+ /** What is taking the lock, for the "busy" message and the lock body. */
43
+ command?: string;
44
+ /** Total wait for a live holder. Default {@link BOT_LOCK_WAIT_MS}. */
45
+ waitMs?: number;
46
+ /** Poll interval while waiting. */
47
+ pollMs?: number;
48
+ /** Stale age. Default {@link BOT_LOCK_STALE_MS}. */
49
+ staleMs?: number;
50
+ /** Bots root override (tests). */
51
+ root?: string;
52
+ now?: () => number;
53
+ isPidAlive?: (pid: number) => boolean;
54
+ /** Called once when this call has to wait for another holder. */
55
+ onWait?: (holder: BotLockHolder | null) => void;
56
+ /** Called when a stale holder is reclaimed. */
57
+ onReclaim?: (holder: BotLockHolder | null, why: string) => void;
58
+ }
59
+ export declare class BotLockBusyError extends Error {
60
+ readonly botName: string;
61
+ readonly holder: BotLockHolder | null;
62
+ readonly expected: true;
63
+ constructor(botName: string, holder: BotLockHolder | null);
64
+ }
65
+ export declare function botLockPath(name: string, root?: string): string;
66
+ /**
67
+ * Why an existing lock may be taken over, or null while its holder is live.
68
+ * Exported for tests.
69
+ */
70
+ export declare function botLockStaleReason(raw: string, mtimeMs: number, opts: {
71
+ now: number;
72
+ staleMs: number;
73
+ isPidAlive: (pid: number) => boolean;
74
+ host: string;
75
+ }): string | null;
76
+ /**
77
+ * Let go of every lock this process holds. Runs on process exit, so a command
78
+ * that ends through process.exit (every `hq bot` failure does) frees its bot
79
+ * at once instead of leaving it to the stale-holder reclaim.
80
+ */
81
+ export declare function releaseAllHeldBotLocks(): void;
82
+ /** Take the bot's lock (waiting for a live holder), run `fn`, and let go. */
83
+ export declare function withBotLock<T>(name: string, fn: () => Promise<T>, opts?: BotLockOptions): Promise<T>;
84
+ //# sourceMappingURL=lock.d.ts.map