@indigoai-us/hq-cli 5.345.197 → 5.345.199

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 (118) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/command-catalog.generated.d.ts +393 -12
  3. package/dist/command-catalog.generated.js +482 -12
  4. package/dist/commands/bot-events.d.ts +19 -0
  5. package/dist/commands/bot-events.js +46 -0
  6. package/dist/commands/bot-jobs.d.ts +2 -0
  7. package/dist/commands/bot-jobs.js +43 -2
  8. package/dist/commands/bot.d.ts +5 -0
  9. package/dist/commands/bot.js +33 -1
  10. package/dist/commands/core-resolve-company-native.d.ts +2 -1
  11. package/dist/commands/core-resolve-company-native.js +2 -24
  12. package/dist/commands/deploy-domains.d.ts +14 -2
  13. package/dist/commands/deploy-domains.js +726 -42
  14. package/dist/commands/integrations-connect.js +41 -3
  15. package/dist/commands/lanes-report.d.ts +2 -1
  16. package/dist/commands/lanes-report.js +44 -1
  17. package/dist/commands/lanes.d.ts +9 -0
  18. package/dist/commands/lanes.js +95 -9
  19. package/dist/commands/meetings.js +1 -21
  20. package/dist/commands/secrets.js +27 -6
  21. package/dist/commands/signals-run.d.ts +2 -0
  22. package/dist/commands/signals-run.js +6 -1
  23. package/dist/lanes-drain-hook.js +9 -0
  24. package/dist/lib/bot/api.d.ts +5 -0
  25. package/dist/lib/bot/claude-login.d.ts +31 -0
  26. package/dist/lib/bot/claude-login.js +160 -0
  27. package/dist/lib/bot/company-bind.d.ts +34 -0
  28. package/dist/lib/bot/company-bind.js +112 -0
  29. package/dist/lib/bot/config.js +3 -1
  30. package/dist/lib/bot/events.d.ts +18 -0
  31. package/dist/lib/bot/events.js +160 -0
  32. package/dist/lib/bot/executive-settings.d.ts +10 -0
  33. package/dist/lib/bot/executive-settings.js +43 -0
  34. package/dist/lib/bot/fleet-cron.js +4 -0
  35. package/dist/lib/bot/fleet-local.d.ts +12 -0
  36. package/dist/lib/bot/fleet-local.js +50 -20
  37. package/dist/lib/bot/job-run.js +1 -1
  38. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  39. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  40. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  41. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  42. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  43. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  44. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  45. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  46. package/dist/lib/bot/local-jobs.d.ts +15 -2
  47. package/dist/lib/bot/local-jobs.js +24 -7
  48. package/dist/lib/bot/run.d.ts +7 -49
  49. package/dist/lib/bot/run.js +135 -12
  50. package/dist/lib/bot/session.d.ts +19 -1
  51. package/dist/lib/bot/session.js +75 -28
  52. package/dist/lib/bot/status.d.ts +21 -0
  53. package/dist/lib/core-utils/manifest-slugs.d.ts +2 -0
  54. package/dist/lib/core-utils/manifest-slugs.js +26 -0
  55. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  56. package/dist/lib/doctor/checks/bots.js +47 -1
  57. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  58. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  59. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  60. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  61. package/dist/lib/hot-channels/schedule.js +2 -2
  62. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  63. package/dist/lib/hot-channels/signals-pass.js +51 -17
  64. package/dist/lib/lanes/admission.d.ts +2 -0
  65. package/dist/lib/lanes/admission.js +4 -0
  66. package/dist/lib/lanes/approval-gate.js +4 -1
  67. package/dist/lib/lanes/links.d.ts +8 -1
  68. package/dist/lib/lanes/links.js +84 -3
  69. package/dist/lib/lanes/outcome.d.ts +154 -0
  70. package/dist/lib/lanes/outcome.js +151 -0
  71. package/dist/lib/lanes/reconcile.js +1 -0
  72. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  73. package/dist/lib/lanes/signal-brief-template.js +40 -0
  74. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  75. package/dist/lib/lanes/signal-lane.js +99 -0
  76. package/dist/lib/lanes/source-report.d.ts +37 -0
  77. package/dist/lib/lanes/source-report.js +66 -0
  78. package/dist/lib/lanes/spawn.d.ts +6 -0
  79. package/dist/lib/lanes/spawn.js +21 -0
  80. package/dist/lib/lanes/store.d.ts +3 -0
  81. package/dist/lib/lanes/store.js +64 -0
  82. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  83. package/dist/lib/lanes/story-ledger.js +1 -0
  84. package/dist/lib/lanes/types.d.ts +137 -0
  85. package/dist/lib/lanes/types.js +17 -2
  86. package/dist/lib/lanes/watcher.js +4 -0
  87. package/dist/lib/live-meetings.d.ts +19 -0
  88. package/dist/lib/live-meetings.js +23 -0
  89. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  90. package/dist/lib/mesh/live/spool.d.ts +8 -0
  91. package/dist/lib/mesh/live/spool.js +59 -40
  92. package/dist/lib/plan-limit-nag.d.ts +30 -4
  93. package/dist/lib/plan-limit-nag.js +68 -17
  94. package/dist/lib/signals/engine.d.ts +4 -0
  95. package/dist/lib/signals/engine.js +25 -5
  96. package/dist/lib/signals/incremental.d.ts +42 -0
  97. package/dist/lib/signals/incremental.js +191 -0
  98. package/dist/lib/signals/prior-signals.d.ts +11 -0
  99. package/dist/lib/signals/prior-signals.js +44 -0
  100. package/dist/lib/signals/reply.js +6 -1
  101. package/dist/lib/signals/settings.d.ts +5 -0
  102. package/dist/lib/signals/settings.js +17 -0
  103. package/dist/lib/signals/steps/extract.d.ts +30 -2
  104. package/dist/lib/signals/steps/extract.js +153 -11
  105. package/dist/lib/signals/steps/types.d.ts +1 -0
  106. package/dist/lib/signals/write-guard.js +7 -0
  107. package/dist/secrets-env-action.d.ts +70 -0
  108. package/dist/secrets-env-action.js +220 -2
  109. package/dist/utils/cognito-callback-port.d.ts +15 -0
  110. package/dist/utils/cognito-callback-port.js +51 -0
  111. package/dist/utils/cognito-config.js +2 -3
  112. package/dist/utils/cognito-session.d.ts +7 -6
  113. package/dist/utils/cognito-session.js +14 -14
  114. package/dist/utils/interactive-caller.d.ts +15 -0
  115. package/dist/utils/interactive-caller.js +70 -0
  116. package/dist/utils/login-browser.js +3 -1
  117. package/dist/utils/resident-package-preload.js +4 -0
  118. package/package.json +9 -7
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Can a Claude bot on the HQ Fleet Runtime read a Claude Code login here?
3
+ *
4
+ * The runtime never runs the `claude` binary; it calls Anthropic with the
5
+ * owner's Claude Code OAuth token, read in this order (its
6
+ * `resolve_anthropic_token`): ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN and
7
+ * ANTHROPIC_API_KEY from the environment, the HQ Fleet login file,
8
+ * ~/.claude/.credentials.json, then the macOS Keychain item
9
+ * "Claude Code-credentials". This mirrors that order so `hq doctor` and
10
+ * `hq bot create` can say which sources were checked and why each failed,
11
+ * without printing a token.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { fleetHome } from "./fleet-local.js";
18
+ export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
19
+ export const CLAUDE_LOGIN_ENV_VARS = ["ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"];
20
+ function readKeychainWithSecurity() {
21
+ try {
22
+ const stdout = execFileSync("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], {
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ timeout: 5000,
26
+ });
27
+ return { code: 0, stdout, stderr: "" };
28
+ }
29
+ catch (error) {
30
+ const failure = error;
31
+ const stderr = failure.stderr ? String(failure.stderr) : (failure.message ?? "");
32
+ return { code: typeof failure.status === "number" ? failure.status : -1, stdout: "", stderr };
33
+ }
34
+ }
35
+ function oauthRecord(value) {
36
+ if (!value || typeof value !== "object")
37
+ return null;
38
+ const record = value;
39
+ const accessToken = typeof record.accessToken === "string" ? record.accessToken.trim() : "";
40
+ if (!accessToken)
41
+ return null;
42
+ return {
43
+ accessToken,
44
+ refreshToken: typeof record.refreshToken === "string" ? record.refreshToken : "",
45
+ expiresAt: typeof record.expiresAt === "number" ? record.expiresAt : 0,
46
+ };
47
+ }
48
+ function describeRecord(record, now) {
49
+ if (!record.expiresAt || now < record.expiresAt - 60_000) {
50
+ return { status: "ok", reason: "access token present and not expired", usable: true };
51
+ }
52
+ if (record.refreshToken) {
53
+ return { status: "expired", reason: "access token expired; the bot refreshes it with the refresh token", usable: true };
54
+ }
55
+ return { status: "expired", reason: "access token expired and no refresh token", usable: false };
56
+ }
57
+ function readJsonFile(file) {
58
+ if (!fs.existsSync(file))
59
+ return { parsed: null, problem: `${file} does not exist` };
60
+ try {
61
+ return { parsed: JSON.parse(fs.readFileSync(file, "utf8")), problem: null };
62
+ }
63
+ catch (error) {
64
+ return { parsed: null, problem: `${file} could not be read (${error.message})` };
65
+ }
66
+ }
67
+ export function checkClaudeCodeLogin(deps = {}) {
68
+ const env = deps.env ?? process.env;
69
+ const home = deps.home ?? os.homedir();
70
+ const platform = deps.platform ?? process.platform;
71
+ const now = (deps.now ?? Date.now)();
72
+ const sources = [];
73
+ let via = null;
74
+ const found = (source) => {
75
+ if (via === null)
76
+ via = source;
77
+ };
78
+ for (const name of CLAUDE_LOGIN_ENV_VARS) {
79
+ const set = !!env[name]?.trim();
80
+ sources.push({ source: `env:${name}`, status: set ? "ok" : "missing", reason: set ? "set" : "not set in this environment" });
81
+ if (set)
82
+ found(`env:${name}`);
83
+ }
84
+ const fleetLogin = path.join(fleetHome(home, env), ".anthropic_oauth.json");
85
+ const fleetFile = readJsonFile(fleetLogin);
86
+ const fleetRecord = fleetFile.parsed ? oauthRecord(fleetFile.parsed) : null;
87
+ if (fleetRecord) {
88
+ const described = describeRecord(fleetRecord, now);
89
+ sources.push({ source: "hq_fleet_oauth_file", status: described.status, reason: described.reason });
90
+ if (described.usable)
91
+ found("hq_fleet_oauth_file");
92
+ }
93
+ else {
94
+ sources.push({ source: "hq_fleet_oauth_file", status: "missing", reason: fleetFile.problem ?? `${fleetLogin} holds no access token` });
95
+ }
96
+ const credentialsFile = path.join(home, ".claude", ".credentials.json");
97
+ const claudeFile = readJsonFile(credentialsFile);
98
+ const claudeRecord = claudeFile.parsed ? oauthRecord(claudeFile.parsed.claudeAiOauth) : null;
99
+ if (claudeRecord) {
100
+ const described = describeRecord(claudeRecord, now);
101
+ sources.push({ source: "claude_code_credentials_file", status: described.status, reason: described.reason });
102
+ if (described.usable)
103
+ found("claude_code_credentials_file");
104
+ }
105
+ else {
106
+ sources.push({
107
+ source: "claude_code_credentials_file",
108
+ status: "missing",
109
+ reason: claudeFile.problem ?? `${credentialsFile} holds no claudeAiOauth access token`,
110
+ });
111
+ }
112
+ if (platform !== "darwin") {
113
+ sources.push({ source: "macos_keychain", status: "skipped", reason: "not macOS" });
114
+ }
115
+ else {
116
+ const read = (deps.readKeychain ?? readKeychainWithSecurity)();
117
+ if (read.code !== 0) {
118
+ const detail = read.stderr.replace(/\s+/g, " ").trim().slice(0, 200) || `exit ${read.code}`;
119
+ sources.push({ source: "macos_keychain", status: "missing", reason: `no readable '${CLAUDE_KEYCHAIN_SERVICE}' item (${detail})` });
120
+ }
121
+ else {
122
+ let payload;
123
+ try {
124
+ payload = JSON.parse(read.stdout.trim());
125
+ }
126
+ catch {
127
+ payload = null;
128
+ }
129
+ const record = payload && typeof payload === "object" ? oauthRecord(payload.claudeAiOauth) : null;
130
+ if (record) {
131
+ const described = describeRecord(record, now);
132
+ sources.push({ source: "macos_keychain", status: described.status, reason: described.reason });
133
+ if (described.usable)
134
+ found("macos_keychain");
135
+ }
136
+ else {
137
+ sources.push({ source: "macos_keychain", status: "missing", reason: "keychain item has no claudeAiOauth access token" });
138
+ }
139
+ }
140
+ }
141
+ return { signedIn: via !== null, via, sources };
142
+ }
143
+ const SOURCE_LABELS = {
144
+ hq_fleet_oauth_file: "the HQ Fleet login file",
145
+ claude_code_credentials_file: "~/.claude/.credentials.json",
146
+ macos_keychain: "the macOS Keychain",
147
+ };
148
+ /** One sentence listing every source that did not yield a login. */
149
+ export function describeClaudeLoginSources(check) {
150
+ const parts = check.sources
151
+ .filter((entry) => entry.status !== "ok" && entry.status !== "skipped")
152
+ .map((entry) => `${SOURCE_LABELS[entry.source] ?? entry.source.replace("env:", "")}: ${entry.reason}`);
153
+ return parts.length ? `Checked ${parts.join("; ")}.` : "";
154
+ }
155
+ export function claudeLoginFix(botName) {
156
+ return botName
157
+ ? `Run \`claude /login\` in a terminal on this computer, then \`hq bot restart ${botName}\`.`
158
+ : "Run `claude /login` in a terminal on this computer, then retry.";
159
+ }
160
+ //# sourceMappingURL=claude-login.js.map
@@ -11,6 +11,7 @@
11
11
  * Coverage note: Claude bots get the full company hook set (their session id
12
12
  * is in the hook payload). Codex/Grok bots only see HQ_SESSION_ID in the env.
13
13
  */
14
+ import { type BotConfig, type BotKind } from "./config.js";
14
15
  export interface CompanyBindFs {
15
16
  existsSync: (p: string) => boolean;
16
17
  readFileSync: (p: string) => string;
@@ -24,4 +25,37 @@ export declare function ensureBotSessionMeta(hqRoot: string, sessionId: string,
24
25
  created: boolean;
25
26
  changed: boolean;
26
27
  };
28
+ /** The company a bot's sessions are locked to, and where that company came from. */
29
+ export interface BotCompanyBinding {
30
+ slug?: string;
31
+ /** True when the slug was read from the HQ's company list rather than bot.json. */
32
+ inferred: boolean;
33
+ }
34
+ /**
35
+ * Every entry in <hqRoot>/companies/manifest.yaml except `personal` and
36
+ * `_template`; [] when unreadable. Entries are not filtered by slug shape: an
37
+ * uppercase id such as `cmp_01K...` is still a company, and dropping it would
38
+ * make a two-company HQ look like a one-company HQ.
39
+ */
40
+ export declare function readManifestCompanies(hqRoot: string): string[];
41
+ /**
42
+ * The company a bot's turns are locked to. An explicit `companySlug` wins. A
43
+ * company bot with exactly one membership binds it. A personal bot (a legacy
44
+ * bot.json without `kind` is personal) binds the HQ's only company when its
45
+ * manifest lists exactly one; with two or more it stays unbound and never
46
+ * guesses. The setup bot is always unbound: it creates companies. Without a
47
+ * lock the scope guard blocks the turn and the model asks "which company?" in
48
+ * every channel, scheduled jobs included.
49
+ */
50
+ export declare function resolveBotCompanyBinding(config: Pick<BotConfig, "kind" | "companies" | "workerId" | "companySlug" | "hqRoot">, readCompanies?: (hqRoot: string) => string[]): BotCompanyBinding;
51
+ /** The fingerprint ensureBotCompanyScope records for a bot's explicit company scope. */
52
+ export declare function botCompanyScopeKey(kind: BotKind, companies: readonly string[], companySlug: string | undefined): string;
53
+ /**
54
+ * The scope key a CLI before "personal" stopped counting as a membership wrote
55
+ * for a company bot whose bot.json lists "personal" (the restore path writes
56
+ * that list as is), or undefined for any other bot. Same memberships, same
57
+ * scope: the caller passes it as an equivalent key so the upgrade neither
58
+ * archives the bot's conversations nor refuses to start with pending work.
59
+ */
60
+ export declare function legacyPersonalEntryScopeKey(config: Pick<BotConfig, "kind" | "companies" | "workerId" | "companySlug">): string | undefined;
27
61
  //# sourceMappingURL=company-bind.d.ts.map
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { parseManifestSlugs } from "../core-utils/manifest-slugs.js";
17
+ import { effectiveBotCompanies, effectiveBotKind, SETUP_BOT_WORKER_ID } from "./config.js";
16
18
  export const defaultCompanyBindFs = {
17
19
  existsSync: (p) => fs.existsSync(p),
18
20
  readFileSync: (p) => fs.readFileSync(p, "utf8"),
@@ -59,4 +61,114 @@ export function ensureBotSessionMeta(hqRoot, sessionId, companySlug, io = defaul
59
61
  io.writeFileSync(file, body);
60
62
  return { path: file, created: false, changed: true };
61
63
  }
64
+ /**
65
+ * True when every company-level key line in the manifest is one parseManifestSlugs
66
+ * recognises. Company-level means two-space keys under `companies:`, or top-level
67
+ * keys when the manifest has no `companies:` map. Anything else there (quoted or
68
+ * digit-leading keys, tabs, other indentation, list items) returns false.
69
+ */
70
+ function manifestKeysFullyParsed(text) {
71
+ const lines = text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
72
+ const meaningful = (l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^(---|\.\.\.)\s*$/.test(l);
73
+ const header = lines.findIndex((l) => /^companies:[ \t]*$/.test(l));
74
+ if (header === -1) {
75
+ return lines.every((l) => !meaningful(l) || /^[A-Za-z][A-Za-z0-9_-]*:/.test(l) || /^[ ]/.test(l));
76
+ }
77
+ // With a `companies:` map, parseManifestSlugs still counts any other top-level
78
+ // key (`version:`, `deployments:`) as a company, so one would be bound. Only
79
+ // `unaffiliated_repos` and its list items may sit beside the map.
80
+ const topLevelOk = lines.every((l, i) => i === header || !meaningful(l) || /^[ \t]/.test(l) ||
81
+ /^unaffiliated_repos:/.test(l) || /^-(?:[ \t]|$)/.test(l));
82
+ if (!topLevelOk)
83
+ return false;
84
+ let level;
85
+ for (const l of lines.slice(header + 1)) {
86
+ if (!meaningful(l))
87
+ continue;
88
+ const indent = /^[ \t]*/.exec(l)[0];
89
+ if (indent === "")
90
+ break;
91
+ if (indent.includes("\t"))
92
+ return false;
93
+ level ??= indent.length;
94
+ if (level !== 2 || indent.length < level)
95
+ return false;
96
+ if (indent.length === 1 || indent.length === 3)
97
+ return false;
98
+ if (indent.length === level && !/^ {2}(?:[A-Za-z][A-Za-z0-9_-]*|_template):/.test(l))
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ /**
104
+ * Every entry in <hqRoot>/companies/manifest.yaml except `personal` and
105
+ * `_template`; [] when unreadable. Entries are not filtered by slug shape: an
106
+ * uppercase id such as `cmp_01K...` is still a company, and dropping it would
107
+ * make a two-company HQ look like a one-company HQ.
108
+ */
109
+ export function readManifestCompanies(hqRoot) {
110
+ try {
111
+ // A byte order mark (Notepad's UTF-8) would hide the `companies:` header.
112
+ const text = fs.readFileSync(path.join(hqRoot, "companies", "manifest.yaml"), "utf8").replace(/^\uFEFF/, "");
113
+ // A company-level key the parser skips (quoted, digit-leading, odd indent) would
114
+ // be missed by the count and could make a multi-company HQ look single-company.
115
+ if (!manifestKeysFullyParsed(text))
116
+ return [];
117
+ return parseManifestSlugs(text).filter((slug) => slug !== "personal" && slug !== "_template");
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ }
123
+ /**
124
+ * The company a bot's turns are locked to. An explicit `companySlug` wins. A
125
+ * company bot with exactly one membership binds it. A personal bot (a legacy
126
+ * bot.json without `kind` is personal) binds the HQ's only company when its
127
+ * manifest lists exactly one; with two or more it stays unbound and never
128
+ * guesses. The setup bot is always unbound: it creates companies. Without a
129
+ * lock the scope guard blocks the turn and the model asks "which company?" in
130
+ * every channel, scheduled jobs included.
131
+ */
132
+ export function resolveBotCompanyBinding(config, readCompanies = readManifestCompanies) {
133
+ if (config.companySlug)
134
+ return { slug: config.companySlug, inferred: false };
135
+ const kind = effectiveBotKind(config);
136
+ if (kind === "company") {
137
+ const companies = effectiveBotCompanies(config);
138
+ return companies.length === 1 ? { slug: companies[0], inferred: false } : { inferred: false };
139
+ }
140
+ if (config.workerId === SETUP_BOT_WORKER_ID)
141
+ return { inferred: false };
142
+ const hq = readCompanies(config.hqRoot);
143
+ // Bind only when the one entry is also a valid slug; the count above is unfiltered.
144
+ return hq.length === 1 && COMPANY_SLUG_RE.test(hq[0]) ? { slug: hq[0], inferred: true } : { inferred: false };
145
+ }
146
+ /** The fingerprint ensureBotCompanyScope records for a bot's explicit company scope. */
147
+ export function botCompanyScopeKey(kind, companies, companySlug) {
148
+ return JSON.stringify({ kind, companies: [...companies].sort(), companySlug: companySlug ?? null });
149
+ }
150
+ /**
151
+ * The scope key a CLI before "personal" stopped counting as a membership wrote
152
+ * for a company bot whose bot.json lists "personal" (the restore path writes
153
+ * that list as is), or undefined for any other bot. Same memberships, same
154
+ * scope: the caller passes it as an equivalent key so the upgrade neither
155
+ * archives the bot's conversations nor refuses to start with pending work.
156
+ */
157
+ export function legacyPersonalEntryScopeKey(config) {
158
+ const kind = effectiveBotKind(config);
159
+ if (kind !== "company")
160
+ return undefined;
161
+ const raw = (config.companies ?? []).map((entry) => (typeof entry === "string" ? entry.trim() : ""));
162
+ if (!raw.includes("personal"))
163
+ return undefined;
164
+ const members = effectiveBotCompanies(config);
165
+ const listed = [];
166
+ for (const slug of raw) {
167
+ if ((slug === "personal" || members.includes(slug)) && !listed.includes(slug))
168
+ listed.push(slug);
169
+ }
170
+ // The old rule: an explicit companySlug, else the single listed entry.
171
+ const slug = config.companySlug ?? (listed.length === 1 ? listed[0] : undefined);
172
+ return botCompanyScopeKey(kind, listed, slug);
173
+ }
62
174
  //# sourceMappingURL=company-bind.js.map
@@ -73,7 +73,9 @@ export function effectiveBotCompanies(config) {
73
73
  const listed = [];
74
74
  for (const raw of config.companies ?? []) {
75
75
  const slug = typeof raw === "string" ? raw.trim() : "";
76
- if (slug && COMPANY_SLUG_RE.test(slug) && !listed.includes(slug))
76
+ // "personal" is the reserved no-company scope, never a membership: counting it
77
+ // made a one-company bot look multi-company and left its sessions unbound.
78
+ if (slug && slug !== "personal" && COMPANY_SLUG_RE.test(slug) && !listed.includes(slug))
77
79
  listed.push(slug);
78
80
  }
79
81
  return listed;
@@ -0,0 +1,18 @@
1
+ import type { InboxItem } from "./api.js";
2
+ import { type LocalJob } from "./local-jobs.js";
3
+ /** Rotate journals at five MiB without discarding unread records. */
4
+ export declare const EVENT_ROTATE_BYTES: number;
5
+ export declare const EVENT_PAYLOAD_BYTES: number;
6
+ export interface BotEvent {
7
+ event: string;
8
+ at: string;
9
+ payload: Record<string, unknown>;
10
+ }
11
+ export declare function validateEventPayload(payload: unknown): asserts payload is Record<string, unknown>;
12
+ export declare function emitBotEvent(dir: string, event: string, payload: unknown, now?: Date, alreadyEmitted?: (record: BotEvent) => boolean): BotEvent;
13
+ export declare function eventMatches(job: LocalJob, record: BotEvent): boolean;
14
+ /** Pending items survive a crash between queueing and the next poll. */
15
+ export declare function drainBotEvents(dir: string): InboxItem[];
16
+ export declare function ackBotEvent(dir: string, messageId: string): void;
17
+ export declare function tailBotEvents(dir: string, predicate?: (event: BotEvent) => boolean): BotEvent[];
18
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1,160 @@
1
+ /** Local event journal and durable inbox. Cursor and queued deliveries commit together. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readLocalJobsStrict, withJobsLock } from "./local-jobs.js";
6
+ /** Rotate journals at five MiB without discarding unread records. */
7
+ export const EVENT_ROTATE_BYTES = 5 * 1024 * 1024;
8
+ export const EVENT_PAYLOAD_BYTES = 16 * 1024;
9
+ const journal = "events.jsonl";
10
+ const stateFile = "events-state.json";
11
+ export function validateEventPayload(payload) {
12
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
13
+ throw new Error("Event payload must be a JSON object.");
14
+ if (Buffer.byteLength(JSON.stringify(payload), "utf8") >= EVENT_PAYLOAD_BYTES)
15
+ throw new Error("Event payload must be under 16 KB.");
16
+ }
17
+ function readState(dir) {
18
+ try {
19
+ const state = JSON.parse(fs.readFileSync(path.join(dir, stateFile), "utf8"));
20
+ if (state.v !== 1 || !state.cursors || !Array.isArray(state.pending))
21
+ throw new Error("Invalid event state");
22
+ return state;
23
+ }
24
+ catch (err) {
25
+ if (err.code === "ENOENT")
26
+ return { v: 1, cursors: {}, pending: [] };
27
+ throw err; // Never reset a corrupt cursor and replay the journal.
28
+ }
29
+ }
30
+ function writeState(dir, state) {
31
+ const file = path.join(dir, stateFile);
32
+ const tmp = `${file}.${process.pid}.tmp`;
33
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
34
+ fs.renameSync(tmp, file);
35
+ }
36
+ function files(dir) {
37
+ return fs.readdirSync(dir).filter((f) => /^events\.\d+-[a-f0-9-]+\.jsonl$/.test(f)).sort().concat(journal);
38
+ }
39
+ /** A persisted intent makes both sides of the journal rename recoverable. */
40
+ function recoverRotation(dir, state) {
41
+ if (!state.rotating)
42
+ return;
43
+ const archived = state.rotating;
44
+ if (!fs.existsSync(path.join(dir, archived))) {
45
+ fs.renameSync(path.join(dir, journal), path.join(dir, archived));
46
+ }
47
+ state.cursors[archived] = state.cursors[journal] ?? 0;
48
+ delete state.cursors[journal];
49
+ delete state.rotating;
50
+ writeState(dir, state);
51
+ }
52
+ export function emitBotEvent(dir, event, payload, now = new Date(), alreadyEmitted) {
53
+ if (!event.trim() || event.length > 256 || /[\r\n]/.test(event))
54
+ throw new Error("Event name must be 1–256 characters without newlines.");
55
+ validateEventPayload(payload);
56
+ const record = { event, at: now.toISOString(), payload };
57
+ return withJobsLock(dir, () => {
58
+ const state = readState(dir);
59
+ recoverRotation(dir, state);
60
+ // Optional producer idempotency: scan and append under the same lock as
61
+ // rotation, including archived journals and only fully written lines.
62
+ if (alreadyEmitted) {
63
+ for (const name of files(dir)) {
64
+ const candidate = path.join(dir, name);
65
+ if (!fs.existsSync(candidate))
66
+ continue;
67
+ const lines = fs.readFileSync(candidate, "utf8").split("\n").slice(0, -1);
68
+ for (const line of lines.filter(Boolean)) {
69
+ const prior = JSON.parse(line);
70
+ if (alreadyEmitted(prior))
71
+ return prior;
72
+ }
73
+ }
74
+ }
75
+ const file = path.join(dir, journal);
76
+ const bytes = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
77
+ // A process killed during append may leave an incomplete final line.
78
+ // It was never accepted as an event: discard it before the next append,
79
+ // otherwise that next valid record would join it and poison the drain.
80
+ const size = bytes.length && bytes.at(-1) !== 10 ? bytes.lastIndexOf(10) + 1 : bytes.length;
81
+ if (size !== bytes.length)
82
+ fs.truncateSync(file, size);
83
+ if (size >= EVENT_ROTATE_BYTES) {
84
+ state.rotating = `events.${Date.now()}-${randomUUID()}.jsonl`;
85
+ writeState(dir, state);
86
+ recoverRotation(dir, state);
87
+ }
88
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
89
+ return record;
90
+ });
91
+ }
92
+ export function eventMatches(job, record) {
93
+ return job.trigger?.kind === "event" && job.trigger.event === record.event &&
94
+ Object.entries(job.trigger.filter ?? {}).every(([key, value]) => {
95
+ if (!Object.hasOwn(record.payload, key))
96
+ return false;
97
+ // JSON objects can shadow toString/valueOf with non-callable fields.
98
+ // Such a value cannot match a string filter and must not poison the drain.
99
+ try {
100
+ return String(record.payload[key]) === value;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ });
106
+ }
107
+ /** Pending items survive a crash between queueing and the next poll. */
108
+ export function drainBotEvents(dir) {
109
+ return withJobsLock(dir, () => {
110
+ const state = readState(dir);
111
+ recoverRotation(dir, state);
112
+ const jobs = readLocalJobsStrict(dir);
113
+ for (const name of files(dir)) {
114
+ const file = path.join(dir, name);
115
+ if (!fs.existsSync(file))
116
+ continue;
117
+ let offset = state.cursors[name] ?? 0;
118
+ // Consumed archives need only a stat, not a full read on every poll.
119
+ if (offset === fs.statSync(file).size)
120
+ continue;
121
+ const bytes = fs.readFileSync(file);
122
+ if (offset > bytes.length)
123
+ throw new Error(`Event journal was truncated: ${name}`);
124
+ while (offset < bytes.length) {
125
+ const end = bytes.indexOf(10, offset);
126
+ if (end < 0)
127
+ break; // An interrupted append is never treated as a full event.
128
+ const record = JSON.parse(bytes.subarray(offset, end).toString("utf8"));
129
+ validateEventPayload(record.payload);
130
+ for (const job of jobs.filter((j) => eventMatches(j, record))) {
131
+ state.pending.push({
132
+ messageId: `localjob-event-${randomUUID()}`, channel: "job", jobId: job.jobId,
133
+ prompt: job.prompt, firedAt: record.at, receivedAt: record.at,
134
+ context: { ...job.context, payload: record.payload, event: record },
135
+ });
136
+ }
137
+ offset = end + 1;
138
+ }
139
+ state.cursors[name] = offset;
140
+ }
141
+ writeState(dir, state);
142
+ return state.pending;
143
+ });
144
+ }
145
+ export function ackBotEvent(dir, messageId) {
146
+ withJobsLock(dir, () => {
147
+ const state = readState(dir);
148
+ state.pending = state.pending.filter((item) => item.messageId !== messageId);
149
+ writeState(dir, state);
150
+ });
151
+ }
152
+ export function tailBotEvents(dir, predicate = () => true) {
153
+ return withJobsLock(dir, () => files(dir).flatMap((name) => {
154
+ const file = path.join(dir, name);
155
+ if (!fs.existsSync(file))
156
+ return [];
157
+ return fs.readFileSync(file, "utf8").split("\n").slice(0, -1).filter(Boolean).map((line) => JSON.parse(line));
158
+ }).filter(predicate).slice(-50));
159
+ }
160
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,10 @@
1
+ import { type BotConfig } from "./config.js";
2
+ export declare function executiveRiderEnabled(home?: string): boolean;
3
+ export declare function isExecutiveBot(config: Pick<BotConfig, "name" | "kind" | "workerId">, home?: string): boolean;
4
+ export declare function executiveStatus(config: Pick<BotConfig, "name" | "kind" | "workerId">, dir: string, home?: string): {
5
+ is_executive: boolean;
6
+ rider_enabled: boolean;
7
+ detect_source_last: string | null;
8
+ last_detected_at: string | null;
9
+ };
10
+ //# sourceMappingURL=executive-settings.d.ts.map
@@ -0,0 +1,43 @@
1
+ /** Shared Executive identity and the desktop's default-on rider preference. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { effectiveBotKind } from "./config.js";
6
+ import { tailBotEvents } from "./events.js";
7
+ export function executiveRiderEnabled(home = os.homedir()) {
8
+ try {
9
+ const settings = JSON.parse(fs.readFileSync(path.join(home, ".hq/menubar.json"), "utf8"));
10
+ return settings?.executiveMeetingRider?.enabled !== false;
11
+ }
12
+ catch (err) {
13
+ return err.code === "ENOENT";
14
+ }
15
+ }
16
+ export function isExecutiveBot(config, home = os.homedir()) {
17
+ if (effectiveBotKind(config) !== "personal")
18
+ return false;
19
+ let name = "setup";
20
+ try {
21
+ const value = JSON.parse(fs.readFileSync(path.join(home, ".hq/executive.json"), "utf8"));
22
+ if (value?.bot != null) {
23
+ if (typeof value.bot !== "string" || !value.bot.trim())
24
+ return false;
25
+ name = value.bot;
26
+ }
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "ENOENT")
30
+ return false;
31
+ }
32
+ return config.name === name;
33
+ }
34
+ export function executiveStatus(config, dir, home = os.homedir()) {
35
+ const last = tailBotEvents(dir, (event) => event.event === "meeting.detected").at(-1);
36
+ return {
37
+ is_executive: isExecutiveBot(config, home), rider_enabled: executiveRiderEnabled(home),
38
+ // The first desktop producer did not set source.
39
+ detect_source_last: last ? (last.payload.source === "poll" ? "poll" : "desktop") : null,
40
+ last_detected_at: last?.at ?? null,
41
+ };
42
+ }
43
+ //# sourceMappingURL=executive-settings.js.map
@@ -169,6 +169,10 @@ export function migrateLegacyJobsToFleet(dir, config, ops, now = new Date()) {
169
169
  const skipped = [];
170
170
  const moved = new Set();
171
171
  for (const job of legacy) {
172
+ if (job.trigger || !job.cron) {
173
+ skipped.push(job.jobId);
174
+ continue;
175
+ }
172
176
  let schedule;
173
177
  try {
174
178
  schedule = /^(cron|rate)\(/i.test(job.cron.trim()) ? fleetScheduleFromLegacy(job.cron) : job.cron.trim();
@@ -60,6 +60,8 @@ export interface FleetLocalInstallDeps {
60
60
  fetch?: typeof fetch;
61
61
  token?: () => string;
62
62
  hqReleaseLinks?: (release: FleetLocalReleasePin) => Promise<Map<string, string> | null>;
63
+ /** Like `hqReleaseLinks`, but also says why HQ had no links. */
64
+ hqReleaseLookup?: (release: FleetLocalReleasePin) => Promise<HqFleetReleaseLinkLookup>;
63
65
  now?: () => Date;
64
66
  release?: FleetLocalReleasePin;
65
67
  publicKey?: string | readonly string[];
@@ -205,6 +207,16 @@ export declare function fleetLocalCliCompatibilityWarning(manifest: Buffer, rele
205
207
  /** `gh auth token` fallback for the release download token. */
206
208
  export declare const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15000;
207
209
  export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv, run?: FleetLocalExec): string;
210
+ /** Why HQ gave no download links: no usable person sign-in, or no copy of the release. */
211
+ export type HqFleetReleaseLinkMissing = "signed_out" | "not_published";
212
+ export interface HqFleetReleaseLinkLookup {
213
+ links: Map<string, string> | null;
214
+ reason: HqFleetReleaseLinkMissing | null;
215
+ }
216
+ export declare function lookupHqFleetReleaseLinks(release: FleetLocalReleasePin, deps?: {
217
+ token?: () => Promise<string>;
218
+ fetchApi?: typeof vaultApiFetch;
219
+ }): Promise<HqFleetReleaseLinkLookup>;
208
220
  export declare function fetchHqFleetReleaseLinks(release: FleetLocalReleasePin): Promise<Map<string, string> | null>;
209
221
  export declare const FLEET_LOCAL_LATEST_TIMEOUT_MS = 5000;
210
222
  export declare const FLEET_LOCAL_LATEST_CACHE_MS: number;