@indigoai-us/hq-cli 5.345.188 → 5.345.189

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 (35) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/command-catalog.generated.d.ts +34 -0
  3. package/dist/command-catalog.generated.js +42 -0
  4. package/dist/commands/bot.js +15 -3
  5. package/dist/commands/core-company-index-scope-flag.d.ts +2 -1
  6. package/dist/commands/core-company-index-scope-flag.js +2 -1
  7. package/dist/commands/core.js +0 -5
  8. package/dist/commands/dm-attach.d.ts +73 -0
  9. package/dist/commands/dm-attach.js +104 -0
  10. package/dist/commands/dm.d.ts +10 -0
  11. package/dist/commands/dm.js +114 -2
  12. package/dist/commands/sources.js +15 -0
  13. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +45054 -36140
  14. package/dist/hq-cloud-sync-runner/package.json +1 -1
  15. package/dist/lib/bot/fleet-local.d.ts +3 -1
  16. package/dist/lib/bot/fleet-local.js +9 -3
  17. package/dist/lib/bot/fleet-python.d.ts +6 -0
  18. package/dist/lib/bot/fleet-python.js +24 -5
  19. package/dist/lib/bot/probe.d.ts +7 -1
  20. package/dist/lib/bot/probe.js +11 -3
  21. package/dist/lib/bot/run.d.ts +9 -0
  22. package/dist/lib/bot/run.js +114 -8
  23. package/dist/lib/bot/runtime-failure.d.ts +3 -0
  24. package/dist/lib/bot/runtime-failure.js +20 -2
  25. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  26. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  27. package/dist/lib/index-render/session-scope.d.ts +3 -3
  28. package/dist/lib/index-render/session-scope.js +11 -9
  29. package/dist/lib/index-render/shared.d.ts +6 -4
  30. package/dist/lib/index-render/shared.js +33 -4
  31. package/dist/utils/cognito-session.d.ts +4 -2
  32. package/dist/utils/cognito-session.js +27 -42
  33. package/dist/utils/hq-root-resolver.d.ts +27 -1
  34. package/dist/utils/hq-root-resolver.js +65 -33
  35. package/package.json +14 -14
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.107","bundleSha256":"0df48ddf2618cac4801b8abefa1d2a4b4a915f368bf7d1b2823bcdd104044325","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.107","bundleSha256":"4ffaf3a579e9222aa2e9a1850e355d4a152d6d3323cf0583154cf28e1d45459a","formatVersion":1}
@@ -139,7 +139,9 @@ export declare const HQ_FLEET_LOCAL_RELEASE: FleetLocalReleasePin;
139
139
  export declare const HQ_FLEET_LOCAL_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASJoapvo0WMTg+Wu3Jvt0nzlXLOBEr+G1aJRi0JkEb4A=\n-----END PUBLIC KEY-----\n";
140
140
  export declare function fleetHome(home?: string, env?: NodeJS.ProcessEnv): string;
141
141
  export declare function verifyFleetLocalRelease(files: FleetLocalReleaseFiles, release?: FleetLocalReleasePin, publicKey?: string): void;
142
- export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv): string;
142
+ /** `gh auth token` fallback for the release download token. */
143
+ export declare const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15000;
144
+ export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv, run?: FleetLocalExec): string;
143
145
  export declare function fetchHqFleetReleaseLinks(release: FleetLocalReleasePin): Promise<Map<string, string> | null>;
144
146
  export declare const FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS = 30000;
145
147
  export declare const FLEET_LOCAL_ARCHIVE_DOWNLOAD_TIMEOUT_MS: number;
@@ -105,18 +105,24 @@ export function verifyFleetLocalRelease(files, release = HQ_FLEET_LOCAL_RELEASE,
105
105
  throw expectedError(`HQ Fleet Local ${release.version} manifest signature is invalid.`);
106
106
  }
107
107
  }
108
- export function resolveFleetReleaseToken(env = process.env) {
108
+ /** `gh auth token` fallback for the release download token. */
109
+ export const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15_000;
110
+ export function resolveFleetReleaseToken(env = process.env, run = execFileSync) {
109
111
  for (const name of ["HQ_FLEET_RELEASE_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
110
112
  const value = env[name]?.trim();
111
113
  if (value)
112
114
  return value;
113
115
  }
114
116
  try {
115
- const value = execFileSync("gh", ["auth", "token"], {
117
+ // gh can block on a keychain prompt; a create must fail, not hang.
118
+ const output = run("gh", ["auth", "token"], {
116
119
  encoding: "utf8",
117
120
  stdio: ["ignore", "pipe", "ignore"],
118
121
  windowsHide: true,
119
- }).trim();
122
+ timeout: FLEET_RELEASE_TOKEN_TIMEOUT_MS,
123
+ killSignal: "SIGKILL",
124
+ });
125
+ const value = (Buffer.isBuffer(output) ? output.toString("utf8") : output).trim();
120
126
  if (value)
121
127
  return value;
122
128
  }
@@ -26,6 +26,12 @@ export interface FleetPython {
26
26
  export declare const HQ_FLEET_PYTHON_VERSION = "3.13.17";
27
27
  export declare const HQ_FLEET_PYTHON_TOOL: FleetPythonToolPin;
28
28
  export declare const FLEET_PYTHON_TOOL_DOWNLOAD_TIMEOUT_MS: number;
29
+ /** Unpacking the pinned installer archive. */
30
+ export declare const FLEET_PYTHON_EXTRACT_TIMEOUT_MS: number;
31
+ /** `uv python install`: downloads and unpacks the private Python. */
32
+ export declare const FLEET_PYTHON_INSTALL_TIMEOUT_MS: number;
33
+ /** `uv python find`: local lookup only. */
34
+ export declare const FLEET_PYTHON_FIND_TIMEOUT_MS = 60000;
29
35
  /** One lock for the whole machine: the installer and the private Python are shared by every bot. */
30
36
  export declare const FLEET_PYTHON_LOCK_NAME = "hq-fleet-python";
31
37
  /**
@@ -18,6 +18,12 @@ export const HQ_FLEET_PYTHON_TOOL = {
18
18
  },
19
19
  };
20
20
  export const FLEET_PYTHON_TOOL_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
21
+ /** Unpacking the pinned installer archive. */
22
+ export const FLEET_PYTHON_EXTRACT_TIMEOUT_MS = 2 * 60_000;
23
+ /** `uv python install`: downloads and unpacks the private Python. */
24
+ export const FLEET_PYTHON_INSTALL_TIMEOUT_MS = 15 * 60_000;
25
+ /** `uv python find`: local lookup only. */
26
+ export const FLEET_PYTHON_FIND_TIMEOUT_MS = 60_000;
21
27
  /** One lock for the whole machine: the installer and the private Python are shared by every bot. */
22
28
  export const FLEET_PYTHON_LOCK_NAME = "hq-fleet-python";
23
29
  function expectedError(message) {
@@ -27,9 +33,13 @@ function toolBinaryName(platform) {
27
33
  return platform === "win32" ? "uv.exe" : "uv";
28
34
  }
29
35
  /** The last lines a failed child process wrote, or its exit code, so the user sees why. */
30
- function causeOf(error) {
36
+ function causeOf(error, timeoutMs) {
31
37
  if (error && typeof error === "object") {
32
38
  const failure = error;
39
+ // A child killed by execFileSync's timeout: say so instead of a bare signal.
40
+ if (failure.code === "ETIMEDOUT") {
41
+ return timeoutMs !== undefined ? `timed out after ${Math.round(timeoutMs / 1000)}s` : "timed out";
42
+ }
33
43
  const stderr = failure.stderr === undefined ? "" : (Buffer.isBuffer(failure.stderr) ? failure.stderr.toString("utf8") : String(failure.stderr));
34
44
  const tail = stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3).join(" ");
35
45
  if (tail)
@@ -132,10 +142,15 @@ async function installTool(deps) {
132
142
  const extracted = path.join(staging, "extracted");
133
143
  fs.mkdirSync(extracted, { mode: 0o700 });
134
144
  try {
135
- run(tarExecutable(platform, deps.env ?? process.env), ["-xf", archive, "-C", extracted], { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
145
+ run(tarExecutable(platform, deps.env ?? process.env), ["-xf", archive, "-C", extracted], {
146
+ stdio: ["ignore", "ignore", "pipe"],
147
+ windowsHide: true,
148
+ timeout: FLEET_PYTHON_EXTRACT_TIMEOUT_MS,
149
+ killSignal: "SIGKILL",
150
+ });
136
151
  }
137
152
  catch (error) {
138
- throw expectedError(`HQ Fleet Local could not unpack its runtime installer (${causeOf(error)}).`);
153
+ throw expectedError(`HQ Fleet Local could not unpack its runtime installer (${causeOf(error, FLEET_PYTHON_EXTRACT_TIMEOUT_MS)}).`);
139
154
  }
140
155
  const unpacked = findFile(extracted, toolBinaryName(platform));
141
156
  if (!unpacked)
@@ -179,10 +194,12 @@ async function ensureFleetPythonLocked(root, deps) {
179
194
  stdio: ["ignore", "ignore", "pipe"],
180
195
  env,
181
196
  windowsHide: true,
197
+ timeout: FLEET_PYTHON_INSTALL_TIMEOUT_MS,
198
+ killSignal: "SIGKILL",
182
199
  });
183
200
  }
184
201
  catch (error) {
185
- throw expectedError(`HQ Fleet Local could not install its private Python ${version} (${causeOf(error)}). If this mentions a download, check your internet connection and retry.`);
202
+ throw expectedError(`HQ Fleet Local could not install its private Python ${version} (${causeOf(error, FLEET_PYTHON_INSTALL_TIMEOUT_MS)}). If this mentions a download, check your internet connection and retry.`);
186
203
  }
187
204
  let found;
188
205
  try {
@@ -191,11 +208,13 @@ async function ensureFleetPythonLocked(root, deps) {
191
208
  env,
192
209
  encoding: "utf8",
193
210
  windowsHide: true,
211
+ timeout: FLEET_PYTHON_FIND_TIMEOUT_MS,
212
+ killSignal: "SIGKILL",
194
213
  });
195
214
  found = (Buffer.isBuffer(output) ? output.toString("utf8") : output).trim();
196
215
  }
197
216
  catch (error) {
198
- throw expectedError(`HQ Fleet Local installed its private Python but could not locate it (${causeOf(error)}).`);
217
+ throw expectedError(`HQ Fleet Local installed its private Python but could not locate it (${causeOf(error, FLEET_PYTHON_FIND_TIMEOUT_MS)}).`);
199
218
  }
200
219
  const python = path.resolve(found);
201
220
  if (!found || !fs.existsSync(python) || !isInside(pythonDir, python, platform)) {
@@ -20,7 +20,9 @@ export declare const PROBE_SYSTEM_PROMPT = "You are an HQ bot being checked befo
20
20
  /** A probe that has not answered by then is reported as `transient`. */
21
21
  export declare const PROBE_DEFAULT_TIMEOUT_MS = 120000;
22
22
  /** The plain sentence for a failed probe: what is wrong and the one fix. */
23
- export declare function probeFailureText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string): string;
23
+ export declare function probeFailureText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, usageLimit?: {
24
+ resetsAt?: string;
25
+ }): string;
24
26
  export interface RuntimeProbeResult {
25
27
  ok: boolean;
26
28
  /** null when ok; otherwise the same classes a bot's failure DM uses. */
@@ -30,6 +32,10 @@ export interface RuntimeProbeResult {
30
32
  runtime: BotRuntimeId;
31
33
  /** The model asked for; null = the runtime's own default. */
32
34
  model: string | null;
35
+ /** The tool's plan ran out of usage (class `transient`). */
36
+ usageLimit?: boolean;
37
+ /** When that limit resets, as the tool wrote it ("9pm (Asia/Dhaka)"). */
38
+ resetsAt?: string;
33
39
  /** The CLI was too old for `from`, and the probe answered on `to`. */
34
40
  modelFallback?: {
35
41
  from: string;
@@ -15,7 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { classifyRuntimeError } from "./runtime-failure.js";
18
+ import { classifyRuntimeError, isRuntimeUsageLimit, usageLimitResetsAt } from "./runtime-failure.js";
19
19
  import { runtimeDisplayName } from "./runtime-sign-in.js";
20
20
  import { RuntimeError, runRuntimeTurn, runtimeFor } from "./runtime/index.js";
21
21
  import { turnEnv } from "./run.js";
@@ -24,8 +24,12 @@ export const PROBE_SYSTEM_PROMPT = "You are an HQ bot being checked before it is
24
24
  /** A probe that has not answered by then is reported as `transient`. */
25
25
  export const PROBE_DEFAULT_TIMEOUT_MS = 120_000;
26
26
  /** The plain sentence for a failed probe: what is wrong and the one fix. */
27
- export function probeFailureText(cls, runtime) {
27
+ export function probeFailureText(cls, runtime, usageLimit) {
28
28
  const tool = runtimeDisplayName(runtime);
29
+ if (cls === "transient" && usageLimit) {
30
+ const when = usageLimit.resetsAt ? ` It resets ${usageLimit.resetsAt}.` : "";
31
+ return `${tool} has hit its plan's usage limit.${when} Check again after it resets, or pick another tool.`;
32
+ }
29
33
  switch (cls) {
30
34
  case "signed-out":
31
35
  return `${tool} is not signed in on this computer. Sign in to ${tool} in HQ, then check again.`;
@@ -84,11 +88,15 @@ export async function probeBotRuntime(opts) {
84
88
  const raw = err instanceof RuntimeError ? `${err.message}\n${err.stderrExcerpt}` : err instanceof Error ? err.message : String(err);
85
89
  // Same rule as a bot turn: the runtime's reported error decides, never a tail of its output.
86
90
  const cls = classifyRuntimeError(err);
91
+ const limited = cls === "transient" && isRuntimeUsageLimit(raw);
92
+ const resetsAt = limited ? usageLimitResetsAt(raw) : undefined;
87
93
  return {
88
94
  ok: false,
89
95
  class: cls,
90
- detail: probeFailureText(cls, opts.runtime),
96
+ detail: probeFailureText(cls, opts.runtime, limited ? { resetsAt } : undefined),
91
97
  ...base,
98
+ ...(limited ? { usageLimit: true } : {}),
99
+ ...(resetsAt ? { resetsAt } : {}),
92
100
  diagnostic: raw.slice(0, 400),
93
101
  durationMs: now() - started,
94
102
  };
@@ -71,6 +71,15 @@ export declare const API_BACKOFF_CAP_MS: number;
71
71
  /** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
72
72
  export declare const API_RETRY_AFTER_CAP_MS: number;
73
73
  export declare const API_RETRY_AFTER_FLOOR_MS = 1000;
74
+ /**
75
+ * Delays between startup identity-check tries when HQ cannot be reached
76
+ * (five tries over about three minutes). After the last one the bot stops in
77
+ * a visible failed state the person can retry.
78
+ */
79
+ export declare const STARTUP_VERIFY_RETRY_DELAYS_MS: readonly number[];
80
+ export declare function isTransientApiError(err: unknown): boolean;
81
+ /** Plain failed-state reason after the startup retries run out. */
82
+ export declare function startupUnreachableReason(name: string): string;
74
83
  /**
75
84
  * How long to wait after a failed inbox poll or heartbeat.
76
85
  * 429/5xx (and transport errors) back off exponentially, honouring Retry-After
@@ -107,6 +107,49 @@ export const API_BACKOFF_CAP_MS = 5 * 60_000;
107
107
  /** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
108
108
  export const API_RETRY_AFTER_CAP_MS = 15 * 60_000;
109
109
  export const API_RETRY_AFTER_FLOOR_MS = 1_000;
110
+ /**
111
+ * Delays between startup identity-check tries when HQ cannot be reached
112
+ * (five tries over about three minutes). After the last one the bot stops in
113
+ * a visible failed state the person can retry.
114
+ */
115
+ export const STARTUP_VERIFY_RETRY_DELAYS_MS = [5_000, 15_000, 45_000, 120_000];
116
+ /**
117
+ * True for failures worth retrying: transport errors (no HQ response at all),
118
+ * request timeouts, rate limits and server errors. Other 4xx answers are real
119
+ * refusals and are not retried.
120
+ */
121
+ const TRANSIENT_NETWORK_CODES = new Set([
122
+ "ECONNREFUSED",
123
+ "ECONNRESET",
124
+ "ENOTFOUND",
125
+ "ETIMEDOUT",
126
+ "EAI_AGAIN",
127
+ "UND_ERR_CONNECT_TIMEOUT",
128
+ "UND_ERR_SOCKET",
129
+ ]);
130
+ function isNetworkShapedError(err, depth = 0) {
131
+ if (depth > 4 || !err || typeof err !== "object")
132
+ return false;
133
+ const e = err;
134
+ if (typeof e.code === "string" && TRANSIENT_NETWORK_CODES.has(e.code))
135
+ return true;
136
+ if (e.name === "AbortError" || e.name === "TimeoutError")
137
+ return true;
138
+ if (err instanceof TypeError && e.message === "fetch failed")
139
+ return true;
140
+ return isNetworkShapedError(e.cause, depth + 1);
141
+ }
142
+ export function isTransientApiError(err) {
143
+ if (err instanceof BotApiError)
144
+ return err.status === 408 || err.status === 429 || err.status >= 500;
145
+ // Only network-shaped failures (no HQ answer at all). Auth and programming
146
+ // errors fail at once.
147
+ return isNetworkShapedError(err);
148
+ }
149
+ /** Plain failed-state reason after the startup retries run out. */
150
+ export function startupUnreachableReason(name) {
151
+ return `could not reach HQ to start; check the network, then restart the bot (hq bot start ${name})`;
152
+ }
110
153
  /**
111
154
  * How long to wait after a failed inbox poll or heartbeat.
112
155
  * 429/5xx (and transport errors) back off exponentially, honouring Retry-After
@@ -566,8 +609,57 @@ export async function runBot(deps) {
566
609
  setStatus({ lastError: `own ${runtime.id} login missing: run hq bot login ${config.name} --runtime ${runtime.id}` });
567
610
  }
568
611
  deps.cliUpdate?.startup(log);
612
+ // Stop handling is registered before the startup identity retries, which
613
+ // can wait about three minutes: a SIGTERM during a wait must still release
614
+ // the pid lock and record "stopped" with exit 0. Once the loops start,
615
+ // signals route to the full stop().
616
+ const startupAbort = new AbortController();
617
+ let onStopRequest = () => {
618
+ stopping = true;
619
+ startupAbort.abort();
620
+ };
621
+ const events = deps.processEvents ?? process;
622
+ events.once("SIGTERM", () => onStopRequest("abort"));
623
+ events.once("SIGINT", () => onStopRequest("abort"));
624
+ events.once("SIGUSR2", () => onStopRequest("drain"));
625
+ const stoppedDuringStartup = async () => {
626
+ await finish("stopped", 0, "stop requested");
627
+ return { stop: async () => { }, done, status: () => null, idle: () => true };
628
+ };
569
629
  try {
570
- const record = await api.getAgent(config.agentUid);
630
+ let record;
631
+ // A network drop at login used to fail the bot with exit 0, which launchd
632
+ // KeepAlive{SuccessfulExit:false} never restarts, so one blip kept bots
633
+ // down for a week. Transient failures now retry with a growing delay
634
+ // before the bot settles into a visible failed state.
635
+ for (let attempt = 0;; attempt++) {
636
+ try {
637
+ record = await api.getAgent(config.agentUid);
638
+ if (attempt > 0)
639
+ setStatus({ lastError: undefined });
640
+ break;
641
+ }
642
+ catch (err) {
643
+ const detail = err instanceof Error ? err.message : String(err);
644
+ const delayMs = STARTUP_VERIFY_RETRY_DELAYS_MS[attempt];
645
+ if (stopping)
646
+ return await stoppedDuringStartup();
647
+ if (!isTransientApiError(err))
648
+ throw err;
649
+ if (delayMs === undefined) {
650
+ log("error", `could not reach HQ to verify bot identity: ${detail}`);
651
+ await finish("failed", 0, startupUnreachableReason(config.name));
652
+ return { stop: async () => { }, done, status: () => null, idle: () => true };
653
+ }
654
+ log("warn", `could not reach HQ to verify bot identity (try ${attempt + 1} of ${STARTUP_VERIFY_RETRY_DELAYS_MS.length + 1}): ${detail}; retrying in ${Math.round(delayMs / 1000)}s`);
655
+ setStatus({ lastError: "Could not reach HQ yet; retrying" });
656
+ await sleep(delayMs, startupAbort.signal);
657
+ if (stopping)
658
+ return await stoppedDuringStartup();
659
+ }
660
+ }
661
+ if (stopping)
662
+ return await stoppedDuringStartup();
571
663
  // Promotion keeps the identity and owner. Ownership alone must not let
572
664
  // an old local installation start consuming the cloud bot's inbox.
573
665
  if (record.uid !== config.agentUid || record.computeMode !== "local" || record.botKind !== kind) {
@@ -650,6 +742,10 @@ export async function runBot(deps) {
650
742
  // Read before the kickoff can write its own marker: whatever is here now was
651
743
  // left by a previous run that was cut off mid-turn.
652
744
  const leftovers = readInflightTurns(dir);
745
+ // A signal that arrived anywhere in the startup stretch above ends the bot
746
+ // here, before any outbound DM.
747
+ if (stopping)
748
+ return await stoppedDuringStartup();
653
749
  // ── Intro DM (once) ────────────────────────────────────────────────────────
654
750
  // The kickoff turn is tied to the intro: it runs only in the start that sent
655
751
  // the intro, so introSentAt (persisted before the turn) guarantees it never
@@ -666,6 +762,8 @@ export async function runBot(deps) {
666
762
  let offered = false;
667
763
  if (offerTool) {
668
764
  try {
765
+ if (stopping)
766
+ return await stoppedDuringStartup();
669
767
  await api.sendDm({ toPersonUid: config.ownerUid, body: setupToolOfferText(offerTool, setupWelcomeOpening(config.intro)) });
670
768
  patchBotConfig(dir, { introSentAt: now().toISOString() });
671
769
  log("info", "intro DM sent to owner, leading into the app offer");
@@ -676,6 +774,8 @@ export async function runBot(deps) {
676
774
  log("warn", `coding tool offer could not be sent as the welcome (${err instanceof Error ? err.message : String(err)}); sending the usual welcome`);
677
775
  }
678
776
  }
777
+ if (stopping)
778
+ return await stoppedDuringStartup();
679
779
  if (!offered)
680
780
  try {
681
781
  const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro, { kind: effectiveBotKind(config), companies: effectiveBotCompanies(config) }) });
@@ -701,6 +801,8 @@ export async function runBot(deps) {
701
801
  log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
702
802
  }
703
803
  }
804
+ if (stopping)
805
+ return await stoppedDuringStartup();
704
806
  // ── Heartbeat loop ─────────────────────────────────────────────────────────
705
807
  const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
706
808
  let modelHealth = "ok";
@@ -2639,6 +2741,10 @@ export async function runBot(deps) {
2639
2741
  // app know before any message arrives. A tool that cannot be asked changes
2640
2742
  // nothing; the first turn then reports for itself.
2641
2743
  await runSignInCheck("start");
2744
+ // A signal during the sign-in probe arrives before the full stop() is wired
2745
+ // up below: end here, before the kickoff can send a "needs a sign-in" DM.
2746
+ if (stopping)
2747
+ return await stoppedDuringStartup();
2642
2748
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
2643
2749
  // an owner message sent meanwhile is answered after it in the same session.
2644
2750
  const kickoff = kickoffAfterIntro
@@ -2837,9 +2943,15 @@ export async function runBot(deps) {
2837
2943
  // bot then exits 0 and stays down instead of being restarted.
2838
2944
  cliUpdatePending = null;
2839
2945
  if (stopping) {
2946
+ // Already stopping (an earlier stop, a startup signal, or an update
2947
+ // restart): still wait for the loops and finish, so this call ends the
2948
+ // bot too. finish() is a no-op once the bot has stopped.
2840
2949
  if (mode === "abort")
2841
2950
  for (const turn of currentTurns)
2842
2951
  turn.abort();
2952
+ activeInboxWait?.abort();
2953
+ await Promise.allSettled([heartbeatLoop, inboxLoop]);
2954
+ await finish("stopped", 0, "stop requested");
2843
2955
  return;
2844
2956
  }
2845
2957
  stopping = true;
@@ -2853,13 +2965,7 @@ export async function runBot(deps) {
2853
2965
  await Promise.allSettled([heartbeatLoop, inboxLoop]);
2854
2966
  await finish("stopped", 0, mode === "drain" ? "drain completed (running turns finished)" : "stop requested");
2855
2967
  };
2856
- const onSignal = () => {
2857
- void stop();
2858
- };
2859
- const events = deps.processEvents ?? process;
2860
- events.once("SIGTERM", onSignal);
2861
- events.once("SIGINT", onSignal);
2862
- events.once("SIGUSR2", () => { void stop("drain"); });
2968
+ onStopRequest = (mode) => { void stop(mode); };
2863
2969
  void inboxLoop.then(() => { if (hasPromotionHold(dir))
2864
2970
  void stop("drain"); });
2865
2971
  setStatus({ canDrainForPromotion: true });
@@ -39,6 +39,9 @@ export declare const RUNTIME_REPAIR_ACTIONS: Record<RuntimeRepairClass, RuntimeR
39
39
  export declare function isRuntimeRepairClass(cls: RuntimeFailureClass): cls is RuntimeRepairClass;
40
40
  /** Sort a runtime's failure text into one class. Order matters: most specific first. */
41
41
  export declare function classifyRuntimeFailure(text: string | undefined | null): RuntimeFailureClass;
42
+ export declare function isRuntimeUsageLimit(text: string | undefined | null): boolean;
43
+ /** When a usage limit resets, as the tool wrote it ("9pm (Asia/Dhaka)"), or undefined. */
44
+ export declare function usageLimitResetsAt(text: string | undefined | null): string | undefined;
42
45
  export declare function isBunProcessError(text: string | undefined | null): boolean;
43
46
  /** A process that exits non-zero this soon, with no stream event, never started its turn. */
44
47
  export declare const RUNTIME_CRASH_MAX_MS = 5000;
@@ -44,7 +44,15 @@ const VERSION_TOO_OLD_MODEL = /\bthe ['"`]([^'"`\s]{1,128})['"`] model requires
44
44
  * Codex with a ChatGPT account".
45
45
  */
46
46
  const MODEL_UNSUPPORTED = /there'?s an issue with the selected model|\bnot_found_error\b[^\n]{0,200}\bmodel:\s|\bmodel_not_found\b|\b(?:unknown|invalid|unsupported) model\b|\bthe (?:requested )?model ['"`]?[^'"`\s]{1,128}['"`]? (?:does not exist|is not supported|is not available|was not found|has been deprecated)\b|\bmodel ['"`][^'"`\s]{1,128}['"`] (?:does not exist|is not supported|is not available|not found)\b|\bdo(?:es)? not have access to (?:the )?model\b|\bmodel is not supported when using codex with a chatgpt account\b/i;
47
- const TRANSIENT = /\brate[_ ]limit|\busage[_ ]limit|\byou've hit your usage limit\b|\btoo many requests\b|\b429\b|\boverloaded\b|\b(?:502|503|504|529)\b|\bservice unavailable\b|\bbad gateway\b|\bgateway time-?out\b|\binternal server error\b|\bECONNRESET\b|\bECONNREFUSED\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bENETUNREACH\b|\bsocket hang up\b|\bnetwork (?:error|is unreachable)\b|\bstream (?:disconnected|error)\b|\bconnection (?:reset|refused|closed|error)\b|\btimed out\b|\bexceeded \d+ms \(HQ_BOT_TURN_TIMEOUT_MS\)/i;
47
+ /**
48
+ * The account's plan ran out of usage. Claude Code: "You've hit your weekly
49
+ * limit · resets 9pm (Asia/Dhaka)", "You've hit your session limit". Codex:
50
+ * "You've hit your usage limit".
51
+ */
52
+ const USAGE_LIMIT = /\byou'?ve (?:hit|reached) your (?:[\w-]+ ){0,3}limit\b|\b(?:usage|session|weekly|daily|monthly|5-hour) limit (?:reached|exceeded)\b/i;
53
+ /** "9pm (Asia/Dhaka)" from "... · resets 9pm (Asia/Dhaka)". */
54
+ const USAGE_LIMIT_RESET = /\bresets?(?: at| on)? ([^\n|·]{1,60}?)\s*(?:$|[\n|·])/im;
55
+ const TRANSIENT = /\brate[_ ]limit|\busage[_ ]limit|\btoo many requests\b|\b429\b|\boverloaded\b|\b(?:502|503|504|529)\b|\bservice unavailable\b|\bbad gateway\b|\bgateway time-?out\b|\binternal server error\b|\bECONNRESET\b|\bECONNREFUSED\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bENETUNREACH\b|\bsocket hang up\b|\bnetwork (?:error|is unreachable)\b|\bstream (?:disconnected|error)\b|\bconnection (?:reset|refused|closed|error)\b|\btimed out\b|\bexceeded \d+ms \(HQ_BOT_TURN_TIMEOUT_MS\)/i;
48
56
  /** Sort a runtime's failure text into one class. Order matters: most specific first. */
49
57
  export function classifyRuntimeFailure(text) {
50
58
  const t = (text ?? "").trim();
@@ -58,10 +66,20 @@ export function classifyRuntimeFailure(text) {
58
66
  return "signed-out";
59
67
  if (MODEL_UNSUPPORTED.test(t))
60
68
  return "model-unsupported";
61
- if (TRANSIENT.test(t))
69
+ if (TRANSIENT.test(t) || USAGE_LIMIT.test(t))
62
70
  return "transient";
63
71
  return "unknown";
64
72
  }
73
+ export function isRuntimeUsageLimit(text) {
74
+ return USAGE_LIMIT.test((text ?? "").trim());
75
+ }
76
+ /** When a usage limit resets, as the tool wrote it ("9pm (Asia/Dhaka)"), or undefined. */
77
+ export function usageLimitResetsAt(text) {
78
+ const t = text ?? "";
79
+ if (!USAGE_LIMIT.test(t))
80
+ return undefined;
81
+ return USAGE_LIMIT_RESET.exec(t)?.[1]?.trim() || undefined;
82
+ }
65
83
  /**
66
84
  * Bun's process-level error line (Claude Code is a Bun-compiled binary):
67
85
  * "error: An unknown error occurred (Unexpected)". The excerpt joins stderr