@indigoai-us/hq-cli 5.345.179 → 5.345.180

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 (52) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/command-catalog.generated.d.ts +126 -0
  3. package/dist/command-catalog.generated.js +155 -0
  4. package/dist/commands/bot.d.ts +29 -1
  5. package/dist/commands/bot.js +245 -13
  6. package/dist/commands/channels.d.ts +18 -1
  7. package/dist/commands/channels.js +185 -1
  8. package/dist/commands/lanes.d.ts +1 -1
  9. package/dist/commands/lanes.js +6 -2
  10. package/dist/commands/mesh.js +19 -0
  11. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2285 -1743
  12. package/dist/hq-cloud-sync-runner/package.json +1 -1
  13. package/dist/lib/bot/config.d.ts +8 -0
  14. package/dist/lib/bot/fleet-local.d.ts +1 -1
  15. package/dist/lib/bot/fleet-local.js +82 -30
  16. package/dist/lib/bot/run.js +39 -2
  17. package/dist/lib/bot/runtime-auth.d.ts +119 -0
  18. package/dist/lib/bot/runtime-auth.js +251 -0
  19. package/dist/lib/daemon/catalog.d.ts +1 -0
  20. package/dist/lib/daemon/catalog.js +1 -1
  21. package/dist/lib/daemon/run.d.ts +2 -0
  22. package/dist/lib/daemon/run.js +64 -16
  23. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  24. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  25. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  26. package/dist/lib/hot-channels/contract.d.ts +6 -0
  27. package/dist/lib/hot-channels/ledger.js +17 -0
  28. package/dist/lib/hot-channels/loop.js +2 -0
  29. package/dist/lib/lanes/accounts.d.ts +1 -17
  30. package/dist/lib/lanes/accounts.js +5 -45
  31. package/dist/lib/lanes/provider-home.d.ts +24 -0
  32. package/dist/lib/lanes/provider-home.js +52 -0
  33. package/dist/lib/lanes/spawn.d.ts +12 -0
  34. package/dist/lib/lanes/spawn.js +27 -0
  35. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  36. package/dist/lib/mesh/live/daemon/doctor.js +13 -0
  37. package/dist/lib/mesh/live/daemon/owner-inbox-push.d.ts +94 -0
  38. package/dist/lib/mesh/live/daemon/owner-inbox-push.js +273 -0
  39. package/dist/lib/mesh/live/daemon/run.js +31 -0
  40. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +14 -0
  41. package/dist/lib/mesh/live/daemon/work-subscribe.js +210 -3
  42. package/dist/lib/mesh/live/paths.d.ts +2 -0
  43. package/dist/lib/mesh/live/paths.js +2 -0
  44. package/dist/lib/sync-core/flag.d.ts +1 -1
  45. package/dist/lib/sync-core/flag.js +1 -1
  46. package/dist/lib/sync-core/reconcile.d.ts +1 -1
  47. package/dist/lib/sync-core/reconcile.js +9 -7
  48. package/dist/lib/sync-core/release.d.ts +3 -3
  49. package/dist/lib/sync-core/release.js +4 -4
  50. package/dist/lib/sync-core/rollout-state.d.ts +1 -0
  51. package/dist/lib/sync-core/rollout-state.js +1 -0
  52. package/package.json +8 -8
@@ -56,6 +56,7 @@ import { isAncestorProcess } from "../lib/bot/self-process.js";
56
56
  import { runtimeDisplayName, signInCommand } from "../lib/bot/runtime-sign-in.js";
57
57
  import { classifyRuntimeFailure, runtimeFailureReason } from "../lib/bot/runtime-failure.js";
58
58
  import { PROBE_DEFAULT_TIMEOUT_MS, probeBotRuntime } from "../lib/bot/probe.js";
59
+ import { BOT_LOGIN_RUNTIMES, botAuthEnv, botRuntimeAuth, capacityAccountHome, describeBotAuth, formatBotAuth, isBotLoginRuntime, restoreBotSessions, setAsideBotSessions, signInBotRuntime, signOutBotRuntime, withRuntimeAuth, } from "../lib/bot/runtime-auth.js";
59
60
  import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, healBotDaemonNodeFromJob, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotDisplayName, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, runtimeCrashSinceStart, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_DISPLAY_NAME_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, withBotLock, localBotRole, remoteBotRole, remoteBotDisplayName, personFacingName, writeCloudDisplayName, setupSlugFor, ASSISTANT_ROLE, HQ_FLEET_RUNTIME_ENGINE_ID, authenticateFleetLocalBot, bootstrapFleetLocalBot, buildSystemPrompt, controlFleetLocalBot, ensureFleetPython, fleetLocalGatewayPid, fleetProviderForRuntime, importFleetLocalMachineCredential, fleetProviderNeedsSignIn, fleetDefaultCompanies, fleetOwnerCompanies, fleetRuntimeIsDefault, refreshFleetLocalInstructions, fleetLocalPromotionRuntime, installPinnedFleetLocalDistribution, readPromptSources, removeFleetLocalBot, } from "../lib/bot/index.js";
60
61
  function collectRepeated(value, previous) {
61
62
  return [...previous, value];
@@ -800,6 +801,11 @@ async function runBotCreateLocked(name, opts) {
800
801
  // cost an identity).
801
802
  const worker = opts.worker?.trim() ? resolveBotWorker(hqRoot, opts.worker) : null;
802
803
  const { kind, companies } = resolveCreateKind(opts, worker);
804
+ if (requestedFleetRuntime && kind === "company" && companies.length !== 1) {
805
+ throw Object.assign(new Error("An HQ Fleet company bot must belong to exactly one company."), {
806
+ expected: true,
807
+ });
808
+ }
803
809
  // Companies with the fleet-default flag get their personal bots on
804
810
  // the HQ Fleet Runtime; --legacy-runtime keeps the model-CLI runner.
805
811
  const fleetCandidate = requestedFleetRuntime ||
@@ -808,12 +814,11 @@ async function runBotCreateLocked(name, opts) {
808
814
  // and only where the company turned the flag on.
809
815
  const flaggedCompanies = fleetCandidate && kind === "personal" ? await fleetDefaultCompanies(hqRoot) : [];
810
816
  const useFleetRuntime = requestedFleetRuntime || (fleetCandidate && fleetRuntimeIsDefault(flaggedCompanies));
811
- const ownerCompanies = useFleetRuntime && kind === "personal" ? await fleetOwnerCompanies() : [];
812
- if (useFleetRuntime && kind !== "personal") {
813
- throw Object.assign(new Error("--hq-fleet-runtime creates a personal bot. Drop --kind company and --company."), {
814
- expected: true,
815
- });
816
- }
817
+ const ownerCompanies = useFleetRuntime
818
+ ? kind === "personal"
819
+ ? await fleetOwnerCompanies()
820
+ : companies
821
+ : [];
817
822
  const isSetupBot = name === RESERVED_SETUP_BOT_NAME && kind === "personal" && worker?.id === SETUP_BOT_WORKER_ID;
818
823
  if (name === RESERVED_SETUP_BOT_NAME && kind === "personal" && !isSetupBot) {
819
824
  throw Object.assign(new Error(`The name ${RESERVED_SETUP_BOT_NAME} is reserved for HQ's assistant; create it with --worker ${RESERVED_SETUP_BOT_NAME}.`), { expected: true });
@@ -1387,14 +1392,16 @@ async function runBotStatus(nameArg, opts) {
1387
1392
  const status = readBotStatus(dir);
1388
1393
  const creds = readBotCredsIdentity(dir);
1389
1394
  if (opts.json) {
1390
- console.log(JSON.stringify({ ...row, ...(fleet ? { engine: config.engine } : {}), credsPresent: fleet || !!creds, status }, null, 2));
1395
+ console.log(JSON.stringify({ ...row, ...(fleet ? { engine: config.engine } : {}), credsPresent: fleet || !!creds, login: describeBotAuth(dir, config), status }, null, 2));
1391
1396
  return;
1392
1397
  }
1393
1398
  console.log(`${chalk.bold(name)} ${row.agentUid}`);
1394
1399
  if (fleet)
1395
1400
  console.log(` engine: HQ Fleet Runtime ${config.engine?.version ?? ""} ${chalk.dim(`logs ~/.hq-fleet/profiles/${config.agentUid}/logs`)}`);
1396
1401
  console.log(` kind: ${describeKind(row.kind, row.companies)}`);
1402
+ const login = describeBotAuth(dir, config);
1397
1403
  console.log(` runtime: ${row.runtime} model ${row.model ?? "default"} thinking ${row.effort}${row.effortIsDefault ? " (default)" : ""}`);
1404
+ console.log(` login: ${login.login === "own" && !login.present ? chalk.red(formatBotAuth(login, name)) : formatBotAuth(login, name)}`);
1398
1405
  if (row.workerId)
1399
1406
  console.log(` worker: ${row.workerId}${row.companySlug ? ` (company ${row.companySlug})` : ""} ${chalk.dim(row.workerDir)}`);
1400
1407
  console.log(` memory: ${row.memory} ${chalk.dim(row.memoryDir)}`);
@@ -1421,7 +1428,13 @@ async function runBotStatus(nameArg, opts) {
1421
1428
  if (status.runtimeSignIn) {
1422
1429
  const tool = runtimeDisplayName(status.runtimeSignIn.runtime);
1423
1430
  const what = status.runtimeSignIn.state === "signed-out" ? `${tool} is not signed in for ${name}` : `${tool} needs to sign in again`;
1424
- console.log(` ${chalk.yellow(what)} — sign in from HQ, or run \`${signInCommand(status.runtimeSignIn.runtime)}\`; ${name} checks again on its own`);
1431
+ const which = login.login === "default" ? "" : ` (${formatBotAuth(login, name)})`;
1432
+ const fix = login.login === "own"
1433
+ ? `run \`hq bot login ${name} --runtime ${status.runtimeSignIn.runtime}\`, then \`hq bot restart ${name}\``
1434
+ : login.login === "account"
1435
+ ? `run \`hq capacity login ${status.runtimeSignIn.runtime} --account ${login.account}\`, then \`hq bot restart ${name}\``
1436
+ : `sign in from HQ, or run \`${signInCommand(status.runtimeSignIn.runtime)}\`; ${name} checks again on its own`;
1437
+ console.log(` ${chalk.yellow(`${what}${which}`)} — ${fix}`);
1425
1438
  }
1426
1439
  else if (status.runtimeSignInCheck) {
1427
1440
  const c = status.runtimeSignInCheck;
@@ -1701,9 +1714,9 @@ async function runBotRmLocked(validated, opts) {
1701
1714
  console.log(chalk.dim(` HQ did not finish: ${incomplete.steps.join(", ")}`));
1702
1715
  throw Object.assign(new Error(message), { expected: true });
1703
1716
  }
1704
- export async function runBotSet(nameArg, opts) {
1717
+ export async function runBotSet(nameArg, opts, deps = {}) {
1705
1718
  const name = validateBotName(nameArg);
1706
- await withBotLock(name, () => runBotSetLocked(name, opts), { command: "hq bot set" });
1719
+ await withBotLock(name, () => runBotSetLocked(name, opts, deps), { command: "hq bot set" });
1707
1720
  }
1708
1721
  /** A sentence for a refused name write: the status only, never the server's text. */
1709
1722
  function nameWriteRefused(name, err) {
@@ -1730,12 +1743,48 @@ function nameWriteRefused(name, err) {
1730
1743
  expected: true,
1731
1744
  });
1732
1745
  }
1733
- async function runBotSetLocked(nameArg, opts) {
1746
+ const defaultSetLifecycle = {
1747
+ isRunning: (dir) => {
1748
+ const s = readBotStatus(dir);
1749
+ return !!s && isPidAlive(s.pid) && s.state === "running";
1750
+ },
1751
+ stop: async (name, dir, config) => {
1752
+ await stopBot(name, dir, config, "silent");
1753
+ return waitFor(() => {
1754
+ const s = readBotStatus(dir);
1755
+ return !s || !isPidAlive(s.pid);
1756
+ }, 15_000);
1757
+ },
1758
+ start: async (name, dir, config) => (await startBot(name, dir, config, "silent")).running,
1759
+ };
1760
+ async function runBotSetLocked(nameArg, opts, deps = {}) {
1734
1761
  const { name, dir, config } = requireBot(nameArg);
1735
1762
  if (hasPromotionHold(dir))
1736
1763
  throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
1737
- if (opts.model === undefined && opts.effort === undefined && opts.displayName === undefined) {
1738
- throw Object.assign(new Error("Nothing to change: pass --model <id|default>, --effort <level|default> and/or --display-name <name>."), { expected: true });
1764
+ if (opts.runtime === undefined && opts.account === undefined && opts.model === undefined && opts.effort === undefined && opts.displayName === undefined) {
1765
+ throw Object.assign(new Error("Nothing to change: pass --runtime <runtime>, --account <id|default>, --model <id|default>, --effort <level|default> and/or --display-name <name>."), { expected: true });
1766
+ }
1767
+ if (opts.runtime !== undefined && !isBotRuntimeId(opts.runtime)) {
1768
+ throw Object.assign(new Error(`--runtime must be one of ${BOT_RUNTIMES.join(", ")}`), { expected: true });
1769
+ }
1770
+ const targetRuntime = opts.runtime ?? config.runtime;
1771
+ const switching = targetRuntime !== config.runtime;
1772
+ if ((switching || opts.account !== undefined) && opts.displayName !== undefined) {
1773
+ throw Object.assign(new Error("Change --display-name on its own, not together with --runtime or --account."), { expected: true });
1774
+ }
1775
+ let runtimeAuth = config.runtimeAuth;
1776
+ if (opts.account !== undefined) {
1777
+ const id = opts.account.trim();
1778
+ let auth = null;
1779
+ if (id && id.toLowerCase() !== "default") {
1780
+ // Checked now so a typo never reaches a turn.
1781
+ capacityAccountHome(config.hqRoot, targetRuntime, id);
1782
+ auth = { mode: "account", account: id };
1783
+ }
1784
+ runtimeAuth = withRuntimeAuth(runtimeAuth, targetRuntime, auth);
1785
+ }
1786
+ if (switching || opts.account !== undefined) {
1787
+ return switchRuntimeOrAccount({ name, dir, config, targetRuntime, switching, runtimeAuth, opts, deps });
1739
1788
  }
1740
1789
  // Only the keys this command changes are written, onto a fresh read of
1741
1790
  // bot.json: the running bot writes it too (introSentAt, kickoff state), and
@@ -1812,6 +1861,174 @@ async function runBotSetLocked(nameArg, opts) {
1812
1861
  console.log(chalk.green(`${name} is now called ${displayName}`) + chalk.dim(`${where}${how}`));
1813
1862
  }
1814
1863
  }
1864
+ /**
1865
+ * `hq bot set --runtime/--account`: probe the new setup first (nothing changes
1866
+ * when it cannot answer), stop a running bot, back up bot.json and set the
1867
+ * old runtime's session files aside, save, and start it again. A bot that does
1868
+ * not come back is rolled back to its old settings and sessions.
1869
+ */
1870
+ async function switchRuntimeOrAccount(input) {
1871
+ const { name, dir, config, targetRuntime, switching, runtimeAuth, opts, deps } = input;
1872
+ const life = deps.lifecycle ?? defaultSetLifecycle;
1873
+ const patch = { runtime: targetRuntime, runtimeAuth };
1874
+ // Model ids and thinking levels belong to one runtime: a switch resets what does not carry over.
1875
+ patch.model = opts.model !== undefined ? validateBotModel(opts.model) || undefined : switching ? undefined : config.model;
1876
+ patch.effort =
1877
+ opts.effort !== undefined
1878
+ ? validateBotEffort(targetRuntime, opts.effort) || undefined
1879
+ : config.effort && BOT_EFFORT_LEVELS[targetRuntime].includes(config.effort)
1880
+ ? config.effort
1881
+ : undefined;
1882
+ const candidate = { ...config, ...patch };
1883
+ const probeEnv = botAuthEnv(dir, candidate, process.env);
1884
+ const probe = await (deps.probe ?? probeBotRuntime)({
1885
+ runtime: targetRuntime,
1886
+ ...(candidate.model ? { model: candidate.model } : {}),
1887
+ ...(candidate.effort ? { effort: candidate.effort } : {}),
1888
+ env: probeEnv,
1889
+ });
1890
+ if (!probe.ok) {
1891
+ throw Object.assign(new Error(`${name} stays on ${config.runtime}: ${probe.detail} Nothing was changed.`), { expected: true });
1892
+ }
1893
+ const status = readBotStatus(dir);
1894
+ if (status && isPidAlive(status.pid) && selfStopProbe(status.pid)) {
1895
+ throw Object.assign(new Error(`${name} cannot change its runtime or login from inside one of its own turns. Run this from outside the bot. Nothing was changed.`), { expected: true });
1896
+ }
1897
+ const wasRunning = life.isRunning(dir);
1898
+ if (wasRunning && !(await life.stop(name, dir, config))) {
1899
+ throw Object.assign(new Error(`${name} did not stop, so nothing was changed. Try again, or run hq bot stop ${name} first.`), { expected: true });
1900
+ }
1901
+ let aside = null;
1902
+ // Only the settings this command changed go back, onto a fresh bot.json.
1903
+ const rollBack = () => {
1904
+ if (aside)
1905
+ restoreBotSessions(dir, aside);
1906
+ return patchBotConfig(dir, { runtime: config.runtime, runtimeAuth: config.runtimeAuth, model: config.model, effort: config.effort });
1907
+ };
1908
+ let next;
1909
+ let running = false;
1910
+ let rolledBack = false;
1911
+ try {
1912
+ if (switching)
1913
+ aside = setAsideBotSessions(dir, config.runtime);
1914
+ next = patchBotConfig(dir, patch);
1915
+ if (wasRunning)
1916
+ running = await life.start(name, dir, next);
1917
+ }
1918
+ catch (err) {
1919
+ // Any failure part-way puts the old settings and sessions back, and the bot back up.
1920
+ try {
1921
+ await life.stop(name, dir, config);
1922
+ }
1923
+ catch { /* best effort */ }
1924
+ const old = rollBack();
1925
+ if (wasRunning)
1926
+ await life.start(name, dir, old);
1927
+ throw err;
1928
+ }
1929
+ const login = describeBotAuth(dir, next);
1930
+ createBotLogger(dir)("info", `settings: runtime=${next.runtime} login=${login.login}${login.account ? ` account=${login.account}` : ""} model=${next.model ?? "default"} effort=${effectiveBotEffort(next)}${aside ? ` (sessions set aside in ${aside.archiveDir})` : ""}`);
1931
+ if (wasRunning && !running) {
1932
+ await life.stop(name, dir, next);
1933
+ const old = rollBack();
1934
+ rolledBack = true;
1935
+ running = await life.start(name, dir, old);
1936
+ createBotLogger(dir)("warn", `settings rolled back: ${name} did not start on ${next.runtime}`);
1937
+ }
1938
+ const result = {
1939
+ ok: !rolledBack,
1940
+ name,
1941
+ runtime: rolledBack ? config.runtime : next.runtime,
1942
+ previousRuntime: config.runtime,
1943
+ login: rolledBack ? describeBotAuth(dir, config) : login,
1944
+ model: rolledBack ? config.model ?? null : next.model ?? null,
1945
+ effort: effectiveBotEffort(rolledBack ? config : next),
1946
+ sessionsSetAside: aside && !rolledBack ? { dir: aside.archiveDir, files: aside.moved.length } : null,
1947
+ backup: aside ? path.join(aside.archiveDir, "bot.json") : null,
1948
+ running,
1949
+ restarted: wasRunning,
1950
+ rolledBack,
1951
+ };
1952
+ if (rolledBack)
1953
+ process.exitCode = 1;
1954
+ if (opts.json) {
1955
+ console.log(JSON.stringify(result, null, 2));
1956
+ return;
1957
+ }
1958
+ if (rolledBack) {
1959
+ console.log(chalk.red(`${name} did not start on ${next.runtime}, so it is back on ${config.runtime} with its old sessions. Check: hq bot logs ${name}`));
1960
+ return;
1961
+ }
1962
+ if (switching) {
1963
+ console.log(chalk.green(`${name} now runs on ${next.runtime} (${formatBotAuth(login, name)}), model ${next.model ?? "default"}, thinking ${result.effort}.`));
1964
+ console.log(chalk.dim(` ${aside.moved.length} ${config.runtime} session file(s) set aside in ${aside.archiveDir}; DM history and memory are unchanged.`));
1965
+ }
1966
+ else {
1967
+ console.log(chalk.green(`${name} now uses ${formatBotAuth(login, name)} for ${next.runtime}.`));
1968
+ }
1969
+ if (wasRunning)
1970
+ console.log(chalk.dim(running ? " restarted" : ` did not report running; check: hq bot logs ${name}`));
1971
+ }
1972
+ /** `hq bot login <name>`: sign the bot in with a login of its own. */
1973
+ export async function runBotLogin(nameArg, opts, deps = {}) {
1974
+ const name = validateBotName(nameArg);
1975
+ await withBotLock(name, async () => {
1976
+ const { dir, config } = requireBot(name);
1977
+ const runtime = opts.runtime ?? config.runtime;
1978
+ if (!isBotLoginRuntime(runtime)) {
1979
+ throw Object.assign(new Error(`hq bot login supports ${BOT_LOGIN_RUNTIMES.join(", ")}${opts.runtime ? "" : `; ${name} runs on ${runtime}, pass --runtime`}.`), { expected: true });
1980
+ }
1981
+ if (!(deps.interactive ?? process.stdin.isTTY === true)) {
1982
+ throw Object.assign(new Error("hq bot login needs an interactive terminal: the sign-in opens in your browser."), { expected: true });
1983
+ }
1984
+ signInBotRuntime(dir, runtime, deps.spawn ? { spawn: deps.spawn } : {});
1985
+ const next = patchBotConfig(dir, { runtimeAuth: withRuntimeAuth(config.runtimeAuth, runtime, { mode: "own" }) });
1986
+ createBotLogger(dir)("info", `login: own ${runtime} login set`);
1987
+ const login = describeBotAuth(dir, { ...next, runtime });
1988
+ if (opts.json)
1989
+ console.log(JSON.stringify({ ok: true, name, runtime, login }, null, 2));
1990
+ else
1991
+ console.log(chalk.green(`${name} now uses its own ${runtime} login${runtime === config.runtime ? " from its next message" : ` when it runs on ${runtime}`}.`));
1992
+ }, { command: "hq bot login" });
1993
+ }
1994
+ /** `hq bot logout <name>`: sign the bot's own login out and delete it. */
1995
+ export async function runBotLogout(nameArg, opts, deps = {}) {
1996
+ const name = validateBotName(nameArg);
1997
+ await withBotLock(name, async () => {
1998
+ const { dir, config } = requireBot(name);
1999
+ if (opts.runtime !== undefined && !isBotLoginRuntime(opts.runtime)) {
2000
+ throw Object.assign(new Error(`--runtime must be one of ${BOT_LOGIN_RUNTIMES.join(", ")}`), { expected: true });
2001
+ }
2002
+ const runtimes = opts.runtime ? [opts.runtime] : [...BOT_LOGIN_RUNTIMES];
2003
+ let runtimeAuth = config.runtimeAuth;
2004
+ const results = [];
2005
+ for (const rt of runtimes) {
2006
+ const r = signOutBotRuntime(dir, rt, deps.spawn ? { spawn: deps.spawn } : {});
2007
+ results.push({ runtime: rt, ...r });
2008
+ // A failed vendor logout keeps the folder and the setting, so the bot never drops to your login.
2009
+ if (r.removed && botRuntimeAuth({ runtime: rt, runtimeAuth }, rt)?.mode === "own")
2010
+ runtimeAuth = withRuntimeAuth(runtimeAuth, rt, null);
2011
+ }
2012
+ patchBotConfig(dir, { runtimeAuth });
2013
+ createBotLogger(dir)("info", `logout: ${results.map((r) => `${r.runtime}=${r.removed ? "removed" : "none"}`).join(" ")}`);
2014
+ if (opts.json) {
2015
+ console.log(JSON.stringify({ ok: true, name, results }, null, 2));
2016
+ return;
2017
+ }
2018
+ const failed = results.filter((r) => r.vendorLogout === "failed");
2019
+ for (const r of results) {
2020
+ if (r.removed)
2021
+ console.log(chalk.green(`${name}'s own ${r.runtime} login is signed out and removed.`));
2022
+ else if (r.vendorLogout === "failed")
2023
+ console.log(chalk.yellow(`${r.runtime} logout did not finish, so ${name}'s own ${r.runtime} login was kept. Make sure ${r.runtime} is installed, then run hq bot logout ${name} --runtime ${r.runtime} again.`));
2024
+ }
2025
+ if (failed.length)
2026
+ process.exitCode = 1;
2027
+ if (!results.some((r) => r.removed || r.vendorLogout === "failed"))
2028
+ console.log(`${name} has no login of its own${opts.runtime ? ` for ${opts.runtime}` : ""}.`);
2029
+ console.log(chalk.dim(` ${name} now uses your default sign-in for ${runtimes.join(" and ")} unless an account is set (hq bot set ${name} --account <id>).`));
2030
+ }, { command: "hq bot logout" });
2031
+ }
1815
2032
  /** `hq bot probe`: exit code 0 when the runtime answered, 1 when it did not. */
1816
2033
  export async function runBotProbe(opts, deps = {}) {
1817
2034
  const saved = opts.bot ? requireBot(opts.bot).config : undefined;
@@ -1829,6 +2046,7 @@ export async function runBotProbe(opts, deps = {}) {
1829
2046
  ...(model ? { model } : {}),
1830
2047
  ...(effort ? { effort } : {}),
1831
2048
  timeoutMs,
2049
+ ...(opts.bot && saved ? { env: botAuthEnv(botDir(validateBotName(opts.bot)), { ...saved, runtime }, process.env) } : {}),
1832
2050
  });
1833
2051
  if (!result.ok)
1834
2052
  process.exitCode = 1;
@@ -2622,11 +2840,25 @@ export function registerBotCommand(program) {
2622
2840
  bot
2623
2841
  .command("set <name>")
2624
2842
  .description("Change the model and thinking level a bot uses (applies from its next message), or the name people see for it (saved in HQ; a running bot restarts to use it)")
2843
+ .option("--runtime <runtime>", `Switch the model CLI: ${BOT_RUNTIMES.join("|")}. Checked with one test turn first; the old runtime's sessions are set aside (backed up), DM history and memory stay`)
2844
+ .option("--account <id>", "Use this hq capacity account's login for the bot's runtime, or default for your own sign-in")
2625
2845
  .option("--model <model>", "Model id for the runtime CLI, or default for the CLI's own default")
2626
2846
  .option("--display-name <name>", `The name people see for the bot in HQ (up to ${BOT_DISPLAY_NAME_MAX_CHARS} chars). The handle stays <name>`)
2627
2847
  .option("--effort <level>", `Thinking level (claude ${BOT_EFFORT_LEVELS.claude.join("|")}; codex ${BOT_EFFORT_LEVELS.codex.join("|")}; grok ${BOT_EFFORT_LEVELS.grok.join("|")}), or default (${DEFAULT_BOT_EFFORT})`)
2628
2848
  .option("--json", "Print machine-readable JSON")
2629
2849
  .action((name, opts) => wrap(() => runBotSet(name, opts))());
2850
+ bot
2851
+ .command("login <name>")
2852
+ .description("Give the bot a model login of its own, so it does not use yours: runs the runtime's own sign-in into ~/.hq/bots/<name>/auth/<runtime>")
2853
+ .option("--runtime <runtime>", `${BOT_LOGIN_RUNTIMES.join("|")} (default: the bot's runtime)`)
2854
+ .option("--json", "Print machine-readable JSON")
2855
+ .action((name, opts) => wrap(() => runBotLogin(name, opts))());
2856
+ bot
2857
+ .command("logout <name>")
2858
+ .description("Sign the bot's own model login out and delete it; the bot goes back to your default sign-in")
2859
+ .option("--runtime <runtime>", `${BOT_LOGIN_RUNTIMES.join("|")} (default: both)`)
2860
+ .option("--json", "Print machine-readable JSON")
2861
+ .action((name, opts) => wrap(() => runBotLogout(name, opts))());
2630
2862
  bot
2631
2863
  .command("set-model <name> <model>")
2632
2864
  .description("Set the model this bot asks its runtime for (stored in the bot's own settings; the runtime's own config is never changed), or default to clear it")
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- import { type ChannelSummary } from "./dm.js";
2
+ import { type ChannelSummary, type DmContact } from "./dm.js";
3
3
  /**
4
4
  * Human label for a channel row. Named channels (personal/company) show their
5
5
  * name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
@@ -38,6 +38,23 @@ export declare function parseActiveSince(value: string, now?: number): number |
38
38
  * original order). Pure → unit-testable.
39
39
  */
40
40
  export declare function channelsByActivity<T extends ChannelWithActivity>(channels: T[], sinceMs?: number): T[];
41
+ /**
42
+ * Resolve the `<member>` argument of `hq channels add|remove` to a person or
43
+ * agent uid. Accepts a uid (`prs_…`, `agt_…`), an email, or a display name /
44
+ * @handle matched against the caller's DM contacts (which include the bots the
45
+ * caller owns). Pure → unit-testable. Returns an error sentence on no or
46
+ * ambiguous match so the caller never guesses. Feedback e165b286.
47
+ */
48
+ export declare function resolveMemberTarget(contacts: DmContact[], raw: string): {
49
+ ok: true;
50
+ personUid: string;
51
+ displayName: string;
52
+ } | {
53
+ ok: false;
54
+ error: string;
55
+ };
56
+ /** The server's refusal, as one plain sentence. */
57
+ export declare function channelMembershipError(res: Response, verb: "add" | "remove", fallback: string): Promise<string>;
41
58
  export declare function registerChannelsCommand(program: Command): void;
42
59
  export {};
43
60
  //# sourceMappingURL=channels.d.ts.map
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
3
  import { vaultApiFetch } from "../utils/vault-api.js";
4
4
  import { suggestChannels, withChannelSuggestions } from "./channel-suggest.js";
5
- import { callerFromToken, channelSlug, fetchChannelMembers, fetchChannels, matchChannelsByName, } from "./dm.js";
5
+ import { callerFromToken, channelSlug, fetchChannelMembers, fetchChannels, matchChannelsByName, matchContactByName, } from "./dm.js";
6
6
  /**
7
7
  * Human label for a channel row. Named channels (personal/company) show their
8
8
  * name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
@@ -294,6 +294,176 @@ async function runChannelsDelete(channelName, opts) {
294
294
  process.exit(1);
295
295
  }
296
296
  }
297
+ // ─── Channel membership: add / remove ────────────────────────────────────────
298
+ /**
299
+ * Resolve the `<member>` argument of `hq channels add|remove` to a person or
300
+ * agent uid. Accepts a uid (`prs_…`, `agt_…`), an email, or a display name /
301
+ * @handle matched against the caller's DM contacts (which include the bots the
302
+ * caller owns). Pure → unit-testable. Returns an error sentence on no or
303
+ * ambiguous match so the caller never guesses. Feedback e165b286.
304
+ */
305
+ export function resolveMemberTarget(contacts, raw) {
306
+ const target = raw.trim().replace(/^@/, "");
307
+ if (!target)
308
+ return { ok: false, error: "Name a member: a @handle, an email, or a prs_/agt_ uid." };
309
+ if (/^(prs|agt)_[A-Za-z0-9_-]+$/.test(target)) {
310
+ const known = contacts.find((c) => c.personUid === target);
311
+ return { ok: true, personUid: target, displayName: known?.displayName?.trim() || target };
312
+ }
313
+ if (target.includes("@")) {
314
+ const email = target.toLowerCase();
315
+ const hits = contacts.filter((c) => c.personUid && c.email?.trim().toLowerCase() === email);
316
+ const unique = [...new Map(hits.map((c) => [c.personUid, c])).values()];
317
+ if (unique.length === 1) {
318
+ return { ok: true, personUid: unique[0].personUid, displayName: unique[0].displayName?.trim() || email };
319
+ }
320
+ return {
321
+ ok: false,
322
+ error: unique.length === 0
323
+ ? `No contact with the email '${target}'. They must share a company with you; pass their prs_/agt_ uid to add them anyway.`
324
+ : `'${target}' matches ${unique.length} contacts. Pass the prs_/agt_ uid instead.`,
325
+ };
326
+ }
327
+ const match = matchContactByName(contacts, target);
328
+ if (match?.personUid) {
329
+ return { ok: true, personUid: match.personUid, displayName: match.displayName?.trim() || target };
330
+ }
331
+ const names = contacts
332
+ .map((c) => c.displayName?.trim())
333
+ .filter((n) => Boolean(n))
334
+ .sort((a, b) => a.localeCompare(b));
335
+ return {
336
+ ok: false,
337
+ error: `No contact matches '${raw.trim()}'. Contacts: ${names.slice(0, 12).join(", ") || "(none)"}` +
338
+ `${names.length > 12 ? ", …" : ""}. Use the exact name, an email, or a prs_/agt_ uid.`,
339
+ };
340
+ }
341
+ async function fetchDmContacts(token) {
342
+ const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
343
+ if (!res.ok)
344
+ return [];
345
+ const data = (await res.json().catch(() => ({})));
346
+ return Array.isArray(data.contacts) ? data.contacts : [];
347
+ }
348
+ async function resolveOneChannel(token, channelName) {
349
+ const channels = await fetchChannels(token);
350
+ const matches = matchChannelsByName(channels, channelName);
351
+ if (matches.length === 0) {
352
+ const message = `No channel named '${channelName}'. Run \`hq channels\` to see your channels.`;
353
+ throw new Error(withChannelSuggestions(message, suggestChannels(channels, channelName)));
354
+ }
355
+ if (matches.length > 1) {
356
+ const scopes = matches.map((match) => match.scope ?? "?").join(", ");
357
+ throw new Error(`'${channelName}' matches ${matches.length} channels (${scopes}). Name one of them.`);
358
+ }
359
+ return matches[0];
360
+ }
361
+ /** The server's refusal, as one plain sentence. */
362
+ export async function channelMembershipError(res, verb, fallback) {
363
+ const err = (await res.json().catch(() => ({})));
364
+ switch (err.code) {
365
+ case "CHANNEL_NOT_MEMBER":
366
+ return "Join the channel before adding members (so far you only browse it). Post once with `hq dm <channel> \"…\"`, or add yourself first with your own uid.";
367
+ case "INVITEE_NOT_FOUND":
368
+ return "That person or bot is not reachable from this company.";
369
+ case "CHANNEL_NOT_OWNER":
370
+ return verb === "add"
371
+ ? "Only the channel owner or a company admin can add members."
372
+ : "Only the channel owner or a company admin can remove other members, and the channel owner cannot be removed.";
373
+ default: {
374
+ const detail = err.error ?? err.message;
375
+ return detail ? `${fallback} (HTTP ${res.status}): ${detail}` : `${fallback} (HTTP ${res.status})`;
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * `hq channels add <channel> <member…>`: POST /v1/notify/channels/{id}/members
381
+ * once per member. Agents (`agt_…`) are allowed; this is how an owner adds a
382
+ * personal bot to a channel so everyone there can @mention it. The server
383
+ * reports `alreadyMember` on a repeat, which is printed, not treated as an error.
384
+ */
385
+ async function runChannelsAdd(channelName, members, opts) {
386
+ let failed;
387
+ try {
388
+ const token = await ensureCognitoToken();
389
+ const channel = await resolveOneChannel(token, channelName);
390
+ const contacts = await fetchDmContacts(token);
391
+ const results = [];
392
+ for (const raw of members) {
393
+ const target = resolveMemberTarget(contacts, raw);
394
+ if (!target.ok) {
395
+ results.push({ member: raw, ok: false, error: target.error });
396
+ continue;
397
+ }
398
+ const res = await vaultApiFetch({
399
+ token,
400
+ path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/members`,
401
+ method: "POST",
402
+ body: { toPersonUid: target.personUid },
403
+ });
404
+ if (!res.ok) {
405
+ results.push({ member: raw, personUid: target.personUid, ok: false, error: await channelMembershipError(res, "add", "Could not add the member") });
406
+ continue;
407
+ }
408
+ const data = (await res.json().catch(() => ({})));
409
+ results.push({ member: target.displayName, personUid: target.personUid, ok: true, alreadyMember: data.alreadyMember === true });
410
+ }
411
+ const label = channel.name ?? channelName;
412
+ if (opts.json) {
413
+ console.log(JSON.stringify({ channelId: channel.channelId, name: label, results }, null, 2));
414
+ }
415
+ else {
416
+ for (const r of results) {
417
+ if (r.ok) {
418
+ console.log(r.alreadyMember ? chalk.dim(`${r.member} is already in #${label}.`) : chalk.green(`Added ${r.member} to #${label}.`));
419
+ }
420
+ else {
421
+ console.error(chalk.red(`${r.member}: ${r.error}`));
422
+ }
423
+ }
424
+ }
425
+ failed = results.some((r) => !r.ok);
426
+ }
427
+ catch (err) {
428
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
429
+ failed = true;
430
+ }
431
+ if (failed)
432
+ process.exit(1);
433
+ }
434
+ /** `hq channels remove <channel> <member>`: DELETE /v1/notify/channels/{id}/members/{uid}. */
435
+ async function runChannelsRemove(channelName, member, opts) {
436
+ try {
437
+ const token = await ensureCognitoToken();
438
+ const channel = await resolveOneChannel(token, channelName);
439
+ // The member is in the channel, so resolve against the roster first, then contacts.
440
+ const roster = await fetchChannelMembers(token, channel.channelId);
441
+ const rosterContacts = roster.map((m) => ({ personUid: m.personUid, email: m.email, displayName: m.displayName }));
442
+ let target = resolveMemberTarget(rosterContacts, member);
443
+ if (!target.ok)
444
+ target = resolveMemberTarget(await fetchDmContacts(token), member);
445
+ if (!target.ok)
446
+ throw new Error(target.error);
447
+ const res = await vaultApiFetch({
448
+ token,
449
+ path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/members/${encodeURIComponent(target.personUid)}`,
450
+ method: "DELETE",
451
+ });
452
+ if (!res.ok)
453
+ throw new Error(await channelMembershipError(res, "remove", "Could not remove the member"));
454
+ const label = channel.name ?? channelName;
455
+ if (opts.json) {
456
+ console.log(JSON.stringify({ channelId: channel.channelId, personUid: target.personUid, removed: true }, null, 2));
457
+ }
458
+ else {
459
+ console.log(chalk.green(`Removed ${target.displayName} from #${label}.`));
460
+ }
461
+ }
462
+ catch (err) {
463
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
464
+ process.exit(1);
465
+ }
466
+ }
297
467
  export function registerChannelsCommand(program) {
298
468
  const channels = program
299
469
  .command("channels")
@@ -329,5 +499,19 @@ export function registerChannelsCommand(program) {
329
499
  .action(async (channelName, opts) => {
330
500
  await runChannelsDelete(channelName, opts);
331
501
  });
502
+ channels
503
+ .command("add <channel> <member...>")
504
+ .description("Add people or your own bots to a channel, by @handle, email, or prs_/agt_ uid.")
505
+ .option("--json", "Output the per-member results as JSON.")
506
+ .action(async (channelName, members, opts) => {
507
+ await runChannelsAdd(channelName, members, opts);
508
+ });
509
+ channels
510
+ .command("remove <channel> <member>")
511
+ .description("Remove a member or bot from a channel (owner or company admin), or leave it yourself.")
512
+ .option("--json", "Output the result as JSON.")
513
+ .action(async (channelName, member, opts) => {
514
+ await runChannelsRemove(channelName, member, opts);
515
+ });
332
516
  }
333
517
  //# sourceMappingURL=channels.js.map
@@ -147,7 +147,7 @@ export declare function checkpointInstruction(worker: Pick<ResolvedLaneWorker, "
147
147
  export declare function composeWorkerBrief(worker: ResolvedLaneWorker, taskBrief: string, hqRoot: string): string;
148
148
  /**
149
149
  * A lane with no worker still carries the standing authorization paragraph,
150
- * the foreground-only rule and the temp-home rule.
150
+ * the foreground-only rule, the temp-home rule and the self-review rule.
151
151
  */
152
152
  export declare function composeGeneralBrief(taskBrief: string, hqRoot: string): string;
153
153
  export declare const DISPATCH_TERMINAL_REASON: "outpost_dispatch_failed";
@@ -108,7 +108,7 @@ import { resolveKrFreshnessForShow } from "../lib/lanes/kr-freshness.js";
108
108
  import { ensureSessionHome } from "../lib/lanes/session-relink.js";
109
109
  import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
110
110
  import { readLaneClaudeCredential } from "../lib/lanes/claude-credential.js";
111
- import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, tempHomeRule, spawnLaneWorker, spawnEnvelopeRepairRequest, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
111
+ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, selfReviewRule, tempHomeRule, spawnLaneWorker, spawnEnvelopeRepairRequest, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
112
112
  import { handOffInstallHere } from "../lib/daemon/handoff.js";
113
113
  export { spawnLaneWorker };
114
114
  export function openPullRequestsForProject(hqRoot, metadata) {
@@ -3041,11 +3041,13 @@ export function composeWorkerBrief(worker, taskBrief, hqRoot) {
3041
3041
  "",
3042
3042
  tempHomeRule(hqRoot),
3043
3043
  "",
3044
+ selfReviewRule(hqRoot),
3045
+ "",
3044
3046
  ].join("\n");
3045
3047
  }
3046
3048
  /**
3047
3049
  * A lane with no worker still carries the standing authorization paragraph,
3048
- * the foreground-only rule and the temp-home rule.
3050
+ * the foreground-only rule, the temp-home rule and the self-review rule.
3049
3051
  */
3050
3052
  export function composeGeneralBrief(taskBrief, hqRoot) {
3051
3053
  return [
@@ -3058,6 +3060,8 @@ export function composeGeneralBrief(taskBrief, hqRoot) {
3058
3060
  "",
3059
3061
  tempHomeRule(hqRoot),
3060
3062
  "",
3063
+ selfReviewRule(hqRoot),
3064
+ "",
3061
3065
  ].join("\n");
3062
3066
  }
3063
3067
  function parseMaxRuntimeSeconds(raw, workerId) {
@@ -3071,6 +3071,25 @@ export function registerMeshCommand(program, deps = {}) {
3071
3071
  .description("Report daemon running/MQTT/spool/held/dead-letter/outbox/flush health")
3072
3072
  .option("--json", "Print machine-readable JSON")
3073
3073
  .action((opts) => wrap(() => runDaemonDoctorCmd(opts))());
3074
+ mesh
3075
+ .command("push-status")
3076
+ .description("Report whether the owner DM push signal is live or stale, with the last event time")
3077
+ .option("--json", "Print machine-readable JSON")
3078
+ .action((opts) => wrap(() => runMeshPushStatus(opts))());
3079
+ }
3080
+ async function runMeshPushStatus(opts) {
3081
+ const { ownerPushStatusForAccount, readOwnerInboxPushState, formatOwnerPushStatus } = await import("../lib/mesh/live/daemon/owner-inbox-push.js");
3082
+ const { describeCognitoTokenSource } = await import("../utils/cognito-session.js");
3083
+ const { actorKind } = describeCognitoTokenSource({ home: os.homedir(), env: process.env });
3084
+ const status = ownerPushStatusForAccount(readOwnerInboxPushState(workMeshRoot(undefined, process.env)), Date.now(), actorKind);
3085
+ if (opts.json) {
3086
+ console.log(JSON.stringify(status, null, 2));
3087
+ }
3088
+ else {
3089
+ console.log(formatOwnerPushStatus(status));
3090
+ }
3091
+ if (status.status === "stale")
3092
+ process.exitCode = 1;
3074
3093
  }
3075
3094
  async function runDaemonRun(opts = {}) {
3076
3095
  const { runMeshDaemon } = await import("../lib/mesh/live/daemon/run.js");