@indigoai-us/hq-cli 5.114.0 → 5.115.1

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.
@@ -26,6 +26,11 @@
26
26
  * started (error, crash, owner stop, restart) posts one "did not finish"
27
27
  * message and is NOT run again — it may already have done outward things.
28
28
  * inflight.json marks the turn in progress so a restart can say so.
29
+ * - a turn that fails because the coding tool's sign-in no longer works
30
+ * (runtime-sign-in.ts) is not a failure of the message: the bot says once
31
+ * per conversation that it needs a sign-in, keeps the message un-acked,
32
+ * holds new turns, and tries again every SIGN_IN_RETRY_MS (and at once
33
+ * after a restart). A turn that works clears it.
29
34
  * - failures before the model starts (missing binary) retry with backoff;
30
35
  * 5 model failures in 10 minutes → state `failed` and a clean exit (launchd
31
36
  * does not respawn a clean exit). Owner stops never count.
@@ -39,7 +44,7 @@ import * as fs from "node:fs";
39
44
  import * as path from "node:path";
40
45
  import { fullJitterDelayMs } from "../mesh/live/backoff.js";
41
46
  import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
42
- import { patchBotConfig, readBotConfig } from "./config.js";
47
+ import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, SETUP_BOT_WORKER_ID } from "./config.js";
43
48
  import { ensureBotSessionMeta } from "./company-bind.js";
44
49
  import { readBotCredsIdentity } from "./creds.js";
45
50
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
@@ -50,6 +55,8 @@ import { buildSystemPrompt, introDmText, nonOwnerRefusalText, readPromptSources
50
55
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
51
56
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
52
57
  import { isDmItem, resolveReplyTarget } from "./room-policy.js";
58
+ import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
59
+ import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
53
60
  import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
54
61
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
55
62
  export const INBOX_POLL_INTERVAL_MS = 2_000;
@@ -64,8 +71,38 @@ export const RECENT_MESSAGE_MAX_CHARS = 400;
64
71
  /** Model turns a bot runs at once (each on a different conversation). */
65
72
  export const MAX_PARALLEL_TURNS = 2;
66
73
  export const KICKOFF_MESSAGE_ID = "kickoff";
74
+ /**
75
+ * The environment a model turn's `hq` commands run in.
76
+ *
77
+ * The bot process carries its own machine identity in HQ_MACHINE_CREDS_FILE /
78
+ * HQ_MACHINE_TOKEN_STATE_DIR so its inbox, heartbeat and posts are the bot's.
79
+ * What a turn inherits depends on the bot's kind:
80
+ * - A PERSONAL bot acts as its owner on every turn (DMs, the kickoff, rooms):
81
+ * everything the owner asks for — a company, a bot, an invite, a share —
82
+ * belongs to the owner's account, and the cloud rightly refuses a bot that
83
+ * tries to own it ("No person entity found"). Stripping the marker makes
84
+ * every `hq` command in the turn use the owner's sign-in, the same one the
85
+ * desktop app shares, so no command needs a special case. The bot's id
86
+ * (never a credential) rides along in HQ_BOT_AGENT_UID so what the owner
87
+ * creates in the turn — a company — can add the bot as a member afterwards.
88
+ * - A COMPANY bot acts as itself on every turn, like a cloud agent: the
89
+ * machine identity stays, and the cloud evaluates it through its own
90
+ * company memberships. It never acts as the owner, in DMs or in rooms.
91
+ * Pure so the rule is unit-testable.
92
+ */
93
+ export function turnEnv(base, kind, agentUid) {
94
+ const env = { ...base };
95
+ delete env[HQ_BOT_AGENT_UID_ENV];
96
+ if (kind === "personal") {
97
+ delete env.HQ_MACHINE_CREDS_FILE;
98
+ delete env.HQ_MACHINE_TOKEN_STATE_DIR;
99
+ if (agentUid)
100
+ env[HQ_BOT_AGENT_UID_ENV] = agentUid;
101
+ }
102
+ return env;
103
+ }
67
104
  /** The core worker HQ's setup bot runs. */
68
- export const SETUP_WORKER_ID = "setup";
105
+ export const SETUP_WORKER_ID = SETUP_BOT_WORKER_ID;
69
106
  /** The one message a person gets when a turn on their message did not finish. */
70
107
  export function didNotFinishText(reason, progressPosted) {
71
108
  const r = reason.trim().replace(/[.\s]+$/, "");
@@ -148,6 +185,9 @@ export async function runBot(deps) {
148
185
  const turnCwd = config.hqRoot;
149
186
  // HQ's setup bot is a guided conversation: no "working on it" filler between steps.
150
187
  const isSetupBot = config.workerId === SETUP_WORKER_ID;
188
+ // Personal: every turn acts as the owner. Company: every turn acts as the bot.
189
+ const kind = effectiveBotKind(config);
190
+ const companies = effectiveBotCompanies(config);
151
191
  const lockDeps = defaultPidLockDeps({
152
192
  pid,
153
193
  ...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
@@ -193,14 +233,15 @@ export async function runBot(deps) {
193
233
  await finish("stopped", 0, "local bot held for cloud promotion");
194
234
  return { stop: async () => { }, done, status: () => null, idle: () => true };
195
235
  }
196
- setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false });
197
- log("info", `starting ${config.name} (${config.runtime}) as ${config.agentUid} for ${config.ownerUid}`);
236
+ // A restart is the owner's "try again" after signing in: start clean.
237
+ setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null });
238
+ log("info", `starting ${config.name} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
198
239
  try {
199
240
  const record = await api.getAgent(config.agentUid);
200
241
  // Promotion keeps the identity and owner. Ownership alone must not let
201
242
  // an old local installation start consuming the cloud bot's inbox.
202
- if (record.uid !== config.agentUid || record.computeMode !== "local" || record.botKind !== "personal") {
203
- await finish("failed", 0, `identity ${config.agentUid} is not a personal local bot; this installation cannot run it`);
243
+ if (record.uid !== config.agentUid || record.computeMode !== "local" || record.botKind !== kind) {
244
+ await finish("failed", 0, `identity ${config.agentUid} is not a ${kind} local bot; this installation cannot run it`);
204
245
  return { stop: async () => { }, done, status: () => null, idle: () => true };
205
246
  }
206
247
  const owner = record.ownerUid ?? null;
@@ -224,6 +265,8 @@ export async function runBot(deps) {
224
265
  workerSource: config.workerSource,
225
266
  workerId: config.workerId,
226
267
  companySlug: config.companySlug,
268
+ kind,
269
+ companies,
227
270
  memoryDir: config.memoryDir,
228
271
  sources: readPromptSources(config.hqRoot, config.workerDir, undefined, {
229
272
  memoryDir: config.memoryDir,
@@ -249,7 +292,11 @@ export async function runBot(deps) {
249
292
  // the intro, so introSentAt (persisted before the turn) guarantees it never
250
293
  // runs again on a restart.
251
294
  let kickoffAfterIntro = null;
252
- if (!config.introSentAt) {
295
+ if (config.introSentAt && config.kickoffPendingSignIn && config.kickoff?.trim()) {
296
+ // The kickoff was held for a sign-in on an earlier run; this start retries it.
297
+ kickoffAfterIntro = {};
298
+ }
299
+ else if (!config.introSentAt) {
253
300
  try {
254
301
  const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro) });
255
302
  patchBotConfig(dir, { introSentAt: now().toISOString() });
@@ -264,6 +311,41 @@ export async function runBot(deps) {
264
311
  // ── Heartbeat loop ─────────────────────────────────────────────────────────
265
312
  const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
266
313
  let modelHealth = "ok";
314
+ // ── Coding tool sign-in ────────────────────────────────────────────────────
315
+ const signInRetryMs = Math.max(0, deps.signInRetryMs ?? SIGN_IN_RETRY_MS);
316
+ let signInIssue = null;
317
+ let nextSignInRetryAt = 0;
318
+ /** Conversations already told this time that the bot needs a sign-in. */
319
+ const toldAboutSignIn = new Set();
320
+ const markSignInBroken = (excerpt) => {
321
+ nextSignInRetryAt = now().getTime() + signInRetryMs;
322
+ modelHealth = "degraded";
323
+ if (signInIssue)
324
+ return;
325
+ signInIssue = { state: "expired", runtime: config.runtime, since: now().toISOString() };
326
+ setStatus({ runtimeSignIn: signInIssue, lastError: excerpt.slice(0, 400) });
327
+ log("warn", `${runtime.id} needs to sign in again on this computer; holding turns and retrying every ${Math.round(signInRetryMs / 1000)}s`);
328
+ };
329
+ const markSignInWorks = () => {
330
+ if (!signInIssue)
331
+ return;
332
+ signInIssue = null;
333
+ toldAboutSignIn.clear();
334
+ setStatus({ runtimeSignIn: null });
335
+ log("info", `${runtime.id} sign-in works again; resuming`);
336
+ };
337
+ /** Say once per conversation that the bot cannot work until its tool signs in. */
338
+ const tellSignInNeeded = async (conversation, post, kickoff = false) => {
339
+ if (toldAboutSignIn.has(conversation))
340
+ return;
341
+ try {
342
+ await post(signInNeededText(config.runtime, kickoff));
343
+ toldAboutSignIn.add(conversation);
344
+ }
345
+ catch (err) {
346
+ log("warn", `could not say that ${runtime.id} needs a sign-in: ${err instanceof Error ? err.message : String(err)}`);
347
+ }
348
+ };
267
349
  const heartbeatLoop = (async () => {
268
350
  while (!stopping && state === "running") {
269
351
  try {
@@ -314,20 +396,22 @@ export async function runBot(deps) {
314
396
  // resets). Claude gets the full hook set via its payload session id;
315
397
  // Codex/Grok only see HQ_SESSION_ID.
316
398
  const sid = session?.sessionId ?? randomUUID();
317
- let bind = {};
399
+ const env = turnEnv(process.env, kind, config.agentUid);
400
+ let bind = { env };
318
401
  if (config.companySlug) {
319
402
  try {
320
403
  ensureBotSessionMeta(config.hqRoot, sid, config.companySlug, undefined, now);
321
- bind = { newSessionId: sid, env: { ...process.env, HQ_SESSION_ID: sid } };
404
+ bind = { newSessionId: sid, env: { ...env, HQ_SESSION_ID: sid } };
322
405
  }
323
406
  catch (err) {
324
407
  log("warn", `company bind failed for ${config.companySlug}: ${err instanceof Error ? err.message : String(err)}`);
325
408
  }
326
409
  }
327
- // Only the owner's own DM session (DMs and the kickoff). Room prompts must
328
- // keep their [#channel …] header first, and a room turn can be triggered
329
- // by other people, who must not be handed the owner's companies.
330
- if (deps.ownerContext && sessionScope === "dm") {
410
+ // Only a personal bot, and only in the owner's own DM session (DMs and the
411
+ // kickoff). Room prompts must keep their [#channel …] header first, and a
412
+ // room turn can be triggered by other people, who must not be handed the
413
+ // owner's companies. A company bot acts as itself and never gets it.
414
+ if (deps.ownerContext && sessionScope === "dm" && kind === "personal") {
331
415
  try {
332
416
  const owner = await deps.ownerContext();
333
417
  if (owner.status === "unavailable") {
@@ -384,6 +468,7 @@ export async function runBot(deps) {
384
468
  }
385
469
  }
386
470
  modelHealth = "ok";
471
+ markSignInWorks();
387
472
  log("info", `model turn done in ${Math.round((now().getTime() - tTurnStart) / 1000)}s (${Math.round((tTurnStart - tReceived) / 1000)}s before it)`);
388
473
  if (turn.sessionId) {
389
474
  writeBotSession(dir, {
@@ -543,11 +628,23 @@ export async function runBot(deps) {
543
628
  return;
544
629
  }
545
630
  clearInflight(dir, KICKOFF_MESSAGE_ID);
631
+ if (readBotConfig(dir)?.kickoffPendingSignIn)
632
+ patchBotConfig(dir, { kickoffPendingSignIn: false });
546
633
  const st = readStatus();
547
634
  setStatus({ repliesSent: (st?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
548
635
  log("info", `kickoff reply sent (${outcome.reply.length} chars)`);
549
636
  return;
550
637
  }
638
+ if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
639
+ // Nothing ran: hold the kickoff until the tool can sign in, then run it.
640
+ clearInflight(dir, KICKOFF_MESSAGE_ID);
641
+ patchBotConfig(dir, { kickoffPendingSignIn: true });
642
+ markSignInBroken(outcome.excerpt);
643
+ await tellSignInNeeded("dm", (body) => api.sendDm({ toPersonUid: peerUid, body }), true);
644
+ return;
645
+ }
646
+ if (readBotConfig(dir)?.kickoffPendingSignIn)
647
+ patchBotConfig(dir, { kickoffPendingSignIn: false });
551
648
  let count = 0;
552
649
  if (!outcome.aborted)
553
650
  count = recordTurnFailure(outcome.error, "dm").count;
@@ -589,7 +686,7 @@ export async function runBot(deps) {
589
686
  // 404 on the server by design), then ack so it never comes back.
590
687
  try {
591
688
  if (sender)
592
- await api.sendDm({ toPersonUid: sender, body: nonOwnerRefusalText(config.name) });
689
+ await api.sendDm({ toPersonUid: sender, body: nonOwnerRefusalText(config.name, kind) });
593
690
  }
594
691
  catch {
595
692
  /* refusal delivery is best-effort */
@@ -687,6 +784,17 @@ export async function runBot(deps) {
687
784
  return;
688
785
  }
689
786
  await clearThinking();
787
+ if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
788
+ // The tool refused before doing anything: keep the message for when it
789
+ // can sign in again, and say so once in this conversation.
790
+ clearInflight(dir, id);
791
+ attempts.delete(id);
792
+ markSignInBroken(outcome.excerpt);
793
+ const conversation = markerTarget.kind === "dm" ? "dm" : `room:${markerTarget.channelId}:${item.rootEventId ?? ""}`;
794
+ await tellSignInNeeded(conversation, send);
795
+ log("warn", `kept ${id} until ${runtime.id} can sign in again`);
796
+ return; // un-acked: answered once the sign-in works
797
+ }
690
798
  if (!outcome.started) {
691
799
  // The model never ran (e.g. the binary is missing): nothing happened
692
800
  // outside this Mac, so retrying is safe.
@@ -751,6 +859,9 @@ export async function runBot(deps) {
751
859
  for (const [lane, queue] of lanes) {
752
860
  if (busyLanes.size >= maxParallelTurns || !canStart())
753
861
  return;
862
+ // While the sign-in is broken, one message at a time tries it, on a timer.
863
+ if (signInIssue && (busyLanes.size > 0 || now().getTime() < nextSignInRetryAt))
864
+ return;
754
865
  if (busyLanes.has(lane))
755
866
  continue;
756
867
  const item = queue.shift();
@@ -759,6 +870,8 @@ export async function runBot(deps) {
759
870
  if (!item)
760
871
  continue;
761
872
  busyLanes.add(lane);
873
+ if (signInIssue)
874
+ nextSignInRetryAt = now().getTime() + signInRetryMs;
762
875
  const task = handleItem(item)
763
876
  .catch((err) => {
764
877
  log("warn", `inbox item ${item.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -797,6 +910,16 @@ export async function runBot(deps) {
797
910
  while (!stopping && state === "running") {
798
911
  if (hasPromotionHold(dir))
799
912
  break;
913
+ // A kickoff held for a sign-in retries on the same timer as messages.
914
+ if (busyLanes.size === 0 &&
915
+ (!signInIssue || now().getTime() >= nextSignInRetryAt) &&
916
+ readBotConfig(dir)?.kickoffPendingSignIn) {
917
+ if (signInIssue)
918
+ nextSignInRetryAt = now().getTime() + signInRetryMs;
919
+ await runKickoff().catch((err) => {
920
+ log("error", `kickoff retry crashed: ${err instanceof Error ? err.message : String(err)}`);
921
+ });
922
+ }
800
923
  try {
801
924
  const items = await api.pullInbox(config.agentUid);
802
925
  setStatus({ lastInboxPollAt: now().toISOString() });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The bot's coding tool (Claude Code, Codex, Grok) is installed but its login
3
+ * no longer works: never signed in, expired, or revoked. The vendor CLI can
4
+ * still report itself as logged in (a saved but dead token), so the only
5
+ * reliable evidence is a turn that fails this way.
6
+ *
7
+ * Such a turn fails before the model does anything, so the message is safe to
8
+ * answer later. The bot says once that it needs a sign-in, keeps the message,
9
+ * and tries again on its own (and at once after `hq bot restart`).
10
+ */
11
+ import type { BotRuntimeId } from "./config.js";
12
+ /** Time between the bot's own retries while its sign-in is broken. */
13
+ export declare const SIGN_IN_RETRY_MS = 60000;
14
+ export declare function isRuntimeSignInFailure(text: string | undefined | null): boolean;
15
+ export declare function runtimeDisplayName(runtime: BotRuntimeId | string): string;
16
+ /** Reported in status.json and `hq bot list --json` while the sign-in is broken. */
17
+ export interface RuntimeSignInIssue {
18
+ state: "expired";
19
+ runtime: BotRuntimeId;
20
+ since: string;
21
+ }
22
+ /** The one message a conversation gets when the bot cannot work until its tool signs in again. */
23
+ export declare function signInNeededText(runtime: BotRuntimeId | string, kickoff?: boolean): string;
24
+ //# sourceMappingURL=runtime-sign-in.d.ts.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The bot's coding tool (Claude Code, Codex, Grok) is installed but its login
3
+ * no longer works: never signed in, expired, or revoked. The vendor CLI can
4
+ * still report itself as logged in (a saved but dead token), so the only
5
+ * reliable evidence is a turn that fails this way.
6
+ *
7
+ * Such a turn fails before the model does anything, so the message is safe to
8
+ * answer later. The bot says once that it needs a sign-in, keeps the message,
9
+ * and tries again on its own (and at once after `hq bot restart`).
10
+ */
11
+ /** Time between the bot's own retries while its sign-in is broken. */
12
+ export const SIGN_IN_RETRY_MS = 60_000;
13
+ const SIGN_IN_FAILURE = /failed to authenticate|authentication[_ ]failed|not logged in|not signed in|please (?:run \/login|log ?in|sign ?in)|(?:oauth|access|refresh) token (?:has been |was )?(?:expired|revoked|invalid)|oauth session expired|invalid api key|\b401\b|unauthori[sz]ed|login (?:required|expired)|sign-?in (?:required|expired)/i;
14
+ /** Usage and rate limits are a different problem; a new sign-in does not fix them. */
15
+ const NOT_SIGN_IN = /usage[_ ]limit|rate[_ ]limit|quota|too many requests|credits/i;
16
+ export function isRuntimeSignInFailure(text) {
17
+ const t = (text ?? "").trim();
18
+ if (!t)
19
+ return false;
20
+ return SIGN_IN_FAILURE.test(t) && !NOT_SIGN_IN.test(t);
21
+ }
22
+ export function runtimeDisplayName(runtime) {
23
+ return runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : runtime === "grok" ? "Grok" : runtime;
24
+ }
25
+ /** The one message a conversation gets when the bot cannot work until its tool signs in again. */
26
+ export function signInNeededText(runtime, kickoff = false) {
27
+ const tool = runtimeDisplayName(runtime);
28
+ return (`I can't work right now: ${tool} needs you to sign in again on this computer. ` +
29
+ `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again — no need to send it twice.`);
30
+ }
31
+ //# sourceMappingURL=runtime-sign-in.js.map
@@ -2,6 +2,7 @@
2
2
  * status.json — atomic, metadata-only runtime status (local-bots US-003).
3
3
  */
4
4
  import type { BotRuntimeId } from "./config.js";
5
+ import type { RuntimeSignInIssue } from "./runtime-sign-in.js";
5
6
  export type BotState = "starting" | "running" | "stopped" | "failed";
6
7
  export interface BotStatusFile {
7
8
  v: 1;
@@ -29,6 +30,11 @@ export interface BotStatusFile {
29
30
  * Persisted so later turns skip the failed attempt.
30
31
  */
31
32
  claudePermissionMode?: string;
33
+ /**
34
+ * Set while the coding tool's sign-in is broken (a turn failed for that
35
+ * reason); cleared by the next turn that works. `null` = known to work.
36
+ */
37
+ runtimeSignIn?: RuntimeSignInIssue | null;
32
38
  updatedAt: string;
33
39
  }
34
40
  export declare function defaultBotStatus(pid: number, runtime: BotRuntimeId, now?: () => Date): BotStatusFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.114.0",
3
+ "version": "5.115.1",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {