@indigoai-us/hq-cli 5.122.1 → 5.123.0

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 (36) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +46 -0
  3. package/dist/command-catalog.generated.d.ts +6 -3
  4. package/dist/command-catalog.generated.js +7 -3
  5. package/dist/commands/bot.d.ts +3 -0
  6. package/dist/commands/bot.js +14 -2
  7. package/dist/commands/dm.d.ts +54 -1
  8. package/dist/commands/dm.js +204 -8
  9. package/dist/commands/whoami.d.ts +2 -2
  10. package/dist/commands/whoami.js +18 -19
  11. package/dist/lib/billing/plan-lock.d.ts +64 -69
  12. package/dist/lib/billing/plan-lock.js +230 -161
  13. package/dist/lib/bot/api.d.ts +4 -1
  14. package/dist/lib/bot/api.js +20 -2
  15. package/dist/lib/bot/daemon.d.ts +11 -1
  16. package/dist/lib/bot/daemon.js +44 -2
  17. package/dist/lib/bot/run.d.ts +19 -1
  18. package/dist/lib/bot/run.js +51 -6
  19. package/dist/lib/bot/status.d.ts +5 -0
  20. package/dist/lib/doctor/checks/bots.d.ts +10 -0
  21. package/dist/lib/doctor/checks/bots.js +149 -0
  22. package/dist/lib/doctor/registry.js +4 -0
  23. package/dist/lib/mesh/api.js +96 -28
  24. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
  25. package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
  26. package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
  27. package/dist/lib/mesh/live/daemon/credentials.js +20 -4
  28. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  29. package/dist/lib/mesh/live/daemon/doctor.js +12 -0
  30. package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
  31. package/dist/lib/mesh/live/daemon/install.js +17 -1
  32. package/dist/lib/mesh/live/daemon/run.js +6 -1
  33. package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
  34. package/dist/utils/cli-telemetry.d.ts +21 -0
  35. package/dist/utils/cli-telemetry.js +77 -1
  36. package/package.json +3 -2
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import * as path from "node:path";
15
15
  import { spawn } from "node:child_process";
16
- import { resolveHqBinary, resolveNodeBinary } from "../mesh/live/daemon/install.js";
16
+ import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
17
17
  import { botLogPath, launchdLabel } from "./paths.js";
18
18
  export function detectBotPlatform(platform = process.platform) {
19
19
  if (platform === "darwin")
@@ -192,7 +192,49 @@ function launchdLoaded(p, deps) {
192
192
  return false;
193
193
  return deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]).ok;
194
194
  }
195
- /** Write the unit and load it (RunAtLoad starts the bot immediately). */
195
+ export function launchdPlistProgramArguments(plist) {
196
+ const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
197
+ if (!block)
198
+ return [];
199
+ return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
200
+ }
201
+ /** The hq script path baked into an installed unit, or null if none. */
202
+ export function installedDaemonHqBinary(p, io = fs) {
203
+ if (io.existsSync(p.plistPath)) {
204
+ const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
205
+ return args[1] ?? null;
206
+ }
207
+ if (io.existsSync(p.systemdUnitPath)) {
208
+ const unit = io.readFileSync(p.systemdUnitPath, "utf8");
209
+ const m = unit.match(/^ExecStart=\S+\s+(\S+)/m);
210
+ return m?.[1] ?? null;
211
+ }
212
+ return null;
213
+ }
214
+ /**
215
+ * Rewrite the unit when its hq path is missing or is the pre-move
216
+ * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists.
217
+ */
218
+ export function repairBotDaemonIfStale(p, deps = {}) {
219
+ const io = deps.fsImpl ?? fs;
220
+ let current;
221
+ try {
222
+ current = installedDaemonHqBinary(p, {
223
+ existsSync: (q) => io.existsSync(q),
224
+ readFileSync: (q) => fs.readFileSync(q, "utf8"),
225
+ });
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ if (!current)
231
+ return null;
232
+ const missing = !io.existsSync(current);
233
+ const stale = preferStableHqBinary(current) !== current;
234
+ if (!missing && !stale)
235
+ return null;
236
+ return installBotDaemon(p, deps);
237
+ }
196
238
  export function installBotDaemon(p, deps = {}) {
197
239
  const platform = deps.platform ?? detectBotPlatform();
198
240
  const io = deps.fsImpl ?? fs;
@@ -38,7 +38,7 @@
38
38
  * Everything with a side effect is injectable so the loop is unit-testable.
39
39
  */
40
40
  import { type OwnerContext } from "./owner-context.js";
41
- import type { BotApi, InboxItem } from "./api.js";
41
+ import { type BotApi, type InboxItem } from "./api.js";
42
42
  import type { BotConfig, BotKind } from "./config.js";
43
43
  import { type ProgressPosterOptions } from "./progress.js";
44
44
  import { type BotLogger } from "./log.js";
@@ -46,6 +46,24 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
46
46
  import { type BotState, type BotStatusFile } from "./status.js";
47
47
  export declare const INBOX_POLL_INTERVAL_MS = 2000;
48
48
  export declare const HEARTBEAT_INTERVAL_MS = 30000;
49
+ /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
50
+ export declare const API_BACKOFF_BASE_MS = 4000;
51
+ /** Ceiling for exponential poll/heartbeat backoff. */
52
+ export declare const API_BACKOFF_CAP_MS: number;
53
+ /** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
54
+ export declare const API_RETRY_AFTER_CAP_MS: number;
55
+ export declare const API_RETRY_AFTER_FLOOR_MS = 1000;
56
+ /**
57
+ * How long to wait after a failed inbox poll or heartbeat.
58
+ * 429/5xx (and transport errors) back off exponentially, honouring Retry-After
59
+ * when present. Other 4xx keep the normal interval so a 401 is not stretched.
60
+ */
61
+ export declare function apiPollDelayMs(opts: {
62
+ err: unknown;
63
+ attempt: number;
64
+ intervalMs: number;
65
+ random?: () => number;
66
+ }): number;
49
67
  export declare const FAILURE_WINDOW_MS: number;
50
68
  export declare const FAILURE_LIMIT = 5;
51
69
  export declare const MAX_ATTEMPTS_PER_MESSAGE = 3;
@@ -44,6 +44,7 @@ import * as fs from "node:fs";
44
44
  import * as path from "node:path";
45
45
  import { fullJitterDelayMs } from "../mesh/live/backoff.js";
46
46
  import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
47
+ import { BotApiError } from "./api.js";
47
48
  import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, SETUP_BOT_WORKER_ID } from "./config.js";
48
49
  import { ensureBotSessionMeta } from "./company-bind.js";
49
50
  import { readBotCredsIdentity } from "./creds.js";
@@ -62,6 +63,35 @@ import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
62
63
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
63
64
  export const INBOX_POLL_INTERVAL_MS = 2_000;
64
65
  export const HEARTBEAT_INTERVAL_MS = 30_000;
66
+ /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
67
+ export const API_BACKOFF_BASE_MS = 4_000;
68
+ /** Ceiling for exponential poll/heartbeat backoff. */
69
+ export const API_BACKOFF_CAP_MS = 5 * 60_000;
70
+ /** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
71
+ export const API_RETRY_AFTER_CAP_MS = 15 * 60_000;
72
+ export const API_RETRY_AFTER_FLOOR_MS = 1_000;
73
+ /**
74
+ * How long to wait after a failed inbox poll or heartbeat.
75
+ * 429/5xx (and transport errors) back off exponentially, honouring Retry-After
76
+ * when present. Other 4xx keep the normal interval so a 401 is not stretched.
77
+ */
78
+ export function apiPollDelayMs(opts) {
79
+ const intervalMs = Math.max(0, opts.intervalMs);
80
+ const status = opts.err instanceof BotApiError ? opts.err.status : undefined;
81
+ const retryable = status === 429 || (typeof status === "number" && status >= 500) || !(opts.err instanceof BotApiError);
82
+ if (!retryable)
83
+ return intervalMs;
84
+ const retryAfter = opts.err instanceof BotApiError ? opts.err.retryAfterMs : undefined;
85
+ const header = typeof retryAfter === "number" && Number.isFinite(retryAfter)
86
+ ? Math.min(API_RETRY_AFTER_CAP_MS, Math.max(API_RETRY_AFTER_FLOOR_MS, retryAfter))
87
+ : 0;
88
+ const base = Math.max(intervalMs * 2, API_BACKOFF_BASE_MS);
89
+ const attempt = Math.max(0, opts.attempt);
90
+ const exp = Math.min(API_BACKOFF_CAP_MS, base * 2 ** attempt);
91
+ const random = opts.random ?? Math.random;
92
+ const jittered = Math.floor(exp * (1 + 0.2 * random()));
93
+ return Math.min(API_BACKOFF_CAP_MS, Math.max(intervalMs, exp, header, jittered));
94
+ }
65
95
  export const FAILURE_WINDOW_MS = 10 * 60 * 1000;
66
96
  export const FAILURE_LIMIT = 5;
67
97
  export const MAX_ATTEMPTS_PER_MESSAGE = 3;
@@ -187,6 +217,7 @@ export function preflightCreds(dir, config) {
187
217
  export async function runBot(deps) {
188
218
  const now = deps.now ?? (() => new Date());
189
219
  const sleep = deps.sleep ?? defaultSleep;
220
+ const random = deps.random ?? Math.random;
190
221
  const log = deps.log ?? createBotLogger(deps.dir, now);
191
222
  const pid = deps.pid ?? process.pid;
192
223
  const { dir, config, api, runtime } = deps;
@@ -374,18 +405,23 @@ export async function runBot(deps) {
374
405
  }
375
406
  };
376
407
  const heartbeatLoop = (async () => {
408
+ let heartbeatFails = 0;
377
409
  while (!stopping && state === "running") {
378
410
  try {
379
411
  // The server's component vocabulary is the fleet one (sync/model/task…);
380
412
  // runtime → model, inbox → task, sync → sync.
381
413
  await api.heartbeat(config.agentUid, { model: modelHealth, task: "ok", sync: "ok" });
382
- setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: true });
414
+ heartbeatFails = 0;
415
+ setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: true, lastHeartbeatError: undefined });
416
+ await sleep(heartbeatIntervalMs);
383
417
  }
384
418
  catch (err) {
385
- setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: false });
386
- log("warn", `heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
419
+ heartbeatFails += 1;
420
+ const message = err instanceof Error ? err.message : String(err);
421
+ setStatus({ lastHeartbeatOk: false, lastHeartbeatError: message.slice(0, 400) });
422
+ log("warn", `heartbeat failed: ${message}`);
423
+ await sleep(apiPollDelayMs({ err, attempt: heartbeatFails - 1, intervalMs: heartbeatIntervalMs, random }));
387
424
  }
388
- await sleep(heartbeatIntervalMs);
389
425
  }
390
426
  })();
391
427
  // ── Inbox loop ─────────────────────────────────────────────────────────────
@@ -1000,6 +1036,7 @@ export async function runBot(deps) {
1000
1036
  }
1001
1037
  }
1002
1038
  await kickoff;
1039
+ let inboxFails = 0;
1003
1040
  while (!stopping && state === "running") {
1004
1041
  if (hasPromotionHold(dir))
1005
1042
  break;
@@ -1015,13 +1052,21 @@ export async function runBot(deps) {
1015
1052
  }
1016
1053
  try {
1017
1054
  const items = await api.pullInbox(config.agentUid);
1018
- setStatus({ lastInboxPollAt: now().toISOString() });
1055
+ inboxFails = 0;
1056
+ setStatus({ lastInboxPollAt: now().toISOString(), lastInboxPollOk: true, lastInboxError: undefined });
1019
1057
  for (const item of items)
1020
1058
  enqueue(item);
1021
1059
  pump();
1022
1060
  }
1023
1061
  catch (err) {
1024
- log("warn", `inbox poll failed: ${err instanceof Error ? err.message : String(err)}`);
1062
+ inboxFails += 1;
1063
+ const message = err instanceof Error ? err.message : String(err);
1064
+ setStatus({ lastInboxPollOk: false, lastInboxError: message.slice(0, 400) });
1065
+ log("warn", `inbox poll failed: ${message}`);
1066
+ if (stopping || state !== "running")
1067
+ break;
1068
+ await sleep(apiPollDelayMs({ err, attempt: inboxFails - 1, intervalMs: inboxIntervalMs, random }));
1069
+ continue;
1025
1070
  }
1026
1071
  if (stopping || state !== "running")
1027
1072
  break;
@@ -17,7 +17,12 @@ export interface BotStatusFile {
17
17
  recentFailures: number;
18
18
  lastHeartbeatAt?: string;
19
19
  lastHeartbeatOk?: boolean;
20
+ /** Last heartbeat error, when lastHeartbeatOk is false. */
21
+ lastHeartbeatError?: string;
20
22
  lastInboxPollAt?: string;
23
+ lastInboxPollOk?: boolean;
24
+ /** Last inbox poll error, when lastInboxPollOk is false. */
25
+ lastInboxError?: string;
21
26
  /** Bounded, redacted stderr excerpt of the last model-CLI failure. */
22
27
  lastError?: string;
23
28
  /** Inbound DMs answered since start. */
@@ -0,0 +1,10 @@
1
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
2
+ export declare const BOTS_FAMILY_ID = "bots";
3
+ export declare const BOTS_FAMILY_TITLE = "Local bots";
4
+ export interface BotsDoctorDeps {
5
+ home: () => string;
6
+ root: (home?: string) => string;
7
+ }
8
+ export declare const botsFamily: CheckFamily;
9
+ export declare function checkBots(_context: CheckContext, deps?: BotsDoctorDeps): CheckResult[];
10
+ //# sourceMappingURL=bots.d.ts.map
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Local-bot doctor family: a running bot that cannot poll its inbox or
3
+ * heartbeat still looked healthy (`hq bot status` said online, `hq doctor`
4
+ * had no bot check). Offline, from ~/.hq/bots and installed LaunchAgents.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
10
+ import { readBotConfig } from "../../bot/config.js";
11
+ import { isPidAlive, readBotStatus } from "../../bot/status.js";
12
+ export const BOTS_FAMILY_ID = "bots";
13
+ export const BOTS_FAMILY_TITLE = "Local bots";
14
+ const DEFAULT_DEPS = {
15
+ home: () => os.homedir(),
16
+ root: (home) => botsRoot(home ?? os.homedir()),
17
+ };
18
+ export const botsFamily = {
19
+ id: BOTS_FAMILY_ID,
20
+ title: BOTS_FAMILY_TITLE,
21
+ run: (context) => Promise.resolve(checkBots(context)),
22
+ };
23
+ export function checkBots(_context, deps = DEFAULT_DEPS) {
24
+ try {
25
+ return runChecks(deps);
26
+ }
27
+ catch (error) {
28
+ return [
29
+ {
30
+ status: "UNKNOWN",
31
+ checkId: "bots.error",
32
+ message: `local bot checks could not run: ${error.message}`,
33
+ },
34
+ ];
35
+ }
36
+ }
37
+ function launchdPlistHqBinary(plist) {
38
+ const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
39
+ if (!block)
40
+ return null;
41
+ const args = [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
42
+ return args[1] ?? null;
43
+ }
44
+ function runChecks(deps) {
45
+ const home = deps.home();
46
+ const root = deps.root(home);
47
+ if (!fs.existsSync(root)) {
48
+ return [
49
+ {
50
+ status: "NA",
51
+ checkId: "bots.none",
52
+ message: "No local bots on this computer.",
53
+ },
54
+ ];
55
+ }
56
+ const names = fs
57
+ .readdirSync(root, { withFileTypes: true })
58
+ .filter((d) => d.isDirectory() && readBotConfig(path.join(root, d.name)) !== null)
59
+ .map((d) => d.name)
60
+ .sort();
61
+ if (names.length === 0) {
62
+ return [
63
+ {
64
+ status: "NA",
65
+ checkId: "bots.none",
66
+ message: "No local bots on this computer.",
67
+ },
68
+ ];
69
+ }
70
+ const results = [];
71
+ for (const name of names) {
72
+ const dir = botDir(name, root);
73
+ const config = readBotConfig(dir);
74
+ if (!config)
75
+ continue;
76
+ const status = readBotStatus(dir);
77
+ const alive = !!status && status.state === "running" && isPidAlive(status.pid);
78
+ if (alive && status.lastHeartbeatOk === false) {
79
+ results.push({
80
+ status: "FAIL",
81
+ checkId: `bots.${name}.heartbeat`,
82
+ target: dir,
83
+ message: `${name} is running but cannot heartbeat to HQ${status.lastHeartbeatError ? ` (${status.lastHeartbeatError})` : ""}.`,
84
+ remediation: `hq bot logs ${name}`,
85
+ });
86
+ }
87
+ else if (alive) {
88
+ results.push({
89
+ status: "PASS",
90
+ checkId: `bots.${name}.heartbeat`,
91
+ target: dir,
92
+ message: `${name} last heartbeat succeeded.`,
93
+ });
94
+ }
95
+ if (alive && status.lastInboxPollOk === false) {
96
+ results.push({
97
+ status: "FAIL",
98
+ checkId: `bots.${name}.inbox`,
99
+ target: dir,
100
+ message: `${name} is running but cannot receive messages${status.lastInboxError ? ` (${status.lastInboxError})` : ""}.`,
101
+ remediation: `hq bot logs ${name}`,
102
+ });
103
+ }
104
+ else if (alive) {
105
+ results.push({
106
+ status: "PASS",
107
+ checkId: `bots.${name}.inbox`,
108
+ target: dir,
109
+ message: `${name} last inbox poll succeeded.`,
110
+ });
111
+ }
112
+ const plistPath = path.join(home, "Library", "LaunchAgents", `${launchdLabel(name)}.plist`);
113
+ if (fs.existsSync(plistPath)) {
114
+ let hqPath;
115
+ try {
116
+ hqPath = launchdPlistHqBinary(fs.readFileSync(plistPath, "utf8"));
117
+ }
118
+ catch {
119
+ hqPath = null;
120
+ }
121
+ if (hqPath && !fs.existsSync(hqPath)) {
122
+ results.push({
123
+ status: "FAIL",
124
+ checkId: `bots.${name}.daemon-binary`,
125
+ target: hqPath,
126
+ message: `${name} startup agent points at a missing hq binary.`,
127
+ remediation: `hq bot daemon install ${name}`,
128
+ });
129
+ }
130
+ else if (hqPath) {
131
+ results.push({
132
+ status: "PASS",
133
+ checkId: `bots.${name}.daemon-binary`,
134
+ target: hqPath,
135
+ message: `${name} startup agent hq binary is present.`,
136
+ });
137
+ }
138
+ }
139
+ }
140
+ if (results.length === 0) {
141
+ results.push({
142
+ status: "NA",
143
+ checkId: "bots.idle",
144
+ message: `${names.length} local bot(s) are installed and not running.`,
145
+ });
146
+ }
147
+ return results;
148
+ }
149
+ //# sourceMappingURL=bots.js.map
@@ -22,6 +22,7 @@ import { runtimeHealthFamily } from "./checks/runtime-health.js";
22
22
  import { integrationsFamily } from "./checks/integrations.js";
23
23
  import { syncHealthFamily } from "./checks/sync-health.js";
24
24
  import { workContextFamily } from "./checks/work-context.js";
25
+ import { botsFamily } from "./checks/bots.js";
25
26
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
26
27
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
27
28
  /**
@@ -256,6 +257,9 @@ export function createDefaultRegistry() {
256
257
  // Work context (US-007A): session state files, outbox depth/quarantine,
257
258
  // and default-company mode. Local-only reads under ~/.hq/work-context.
258
259
  registry.register(workContextFamily);
260
+ // Local bots: inbox/heartbeat reachability and a LaunchAgent whose hq
261
+ // path survived an HQ toolchain move. Local-only reads.
262
+ registry.register(botsFamily);
259
263
  return registry;
260
264
  }
261
265
  //# sourceMappingURL=registry.js.map
@@ -139,38 +139,106 @@ function asRecord(value) {
139
139
  ? value
140
140
  : null;
141
141
  }
142
- export async function listActiveThreads(token, companyUid, projectId) {
142
+ function threadRows(data) {
143
+ const rows = Array.isArray(data?.threads)
144
+ ? data.threads
145
+ : Array.isArray(data?.items)
146
+ ? data.items
147
+ : [];
148
+ const out = [];
149
+ for (const row of rows) {
150
+ const rec = asRecord(row);
151
+ if (!rec)
152
+ continue;
153
+ out.push(rec);
154
+ }
155
+ return out;
156
+ }
157
+ function sortThreadsByActivity(threads) {
158
+ return [...threads].sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
159
+ }
160
+ function isActiveThread(thread) {
161
+ const status = String(thread.threadStatus || "").toLowerCase();
162
+ return !status || ACTIVE_STATUSES.has(status);
163
+ }
164
+ function threadKey(thread) {
165
+ const id = typeof thread.threadId === "string" ? thread.threadId.trim() : "";
166
+ return id || undefined;
167
+ }
168
+ async function listThreadsByQuery(token, query) {
169
+ const collected = [];
170
+ let cursor = "";
171
+ for (let page = 0; page < 10; page += 1) {
172
+ const pageQuery = { ...query, limit: "100" };
173
+ if (cursor)
174
+ pageQuery.cursor = cursor;
175
+ const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query: pageQuery }));
176
+ collected.push(...threadRows(data));
177
+ cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
178
+ if (!cursor)
179
+ break;
180
+ }
181
+ return collected;
182
+ }
183
+ async function listThreadsByStatus(token, companyUid) {
143
184
  const collected = [];
144
185
  for (const status of ACTIVE_STATUSES) {
145
- let cursor = "";
146
- for (let page = 0; page < 10; page += 1) {
147
- const query = {
148
- companyUid,
149
- status,
150
- limit: "100",
151
- };
152
- if (cursor)
153
- query.cursor = cursor;
154
- const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query }));
155
- const rows = Array.isArray(data?.threads)
156
- ? data.threads
157
- : Array.isArray(data?.items)
158
- ? data.items
159
- : [];
160
- for (const row of rows) {
161
- const rec = asRecord(row);
162
- if (!rec)
163
- continue;
164
- collected.push(rec);
165
- }
166
- cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
167
- if (!cursor)
168
- break;
186
+ collected.push(...(await listThreadsByQuery(token, { companyUid, status })));
187
+ }
188
+ return collected;
189
+ }
190
+ /**
191
+ * Project threads are often missing from the status index. Prefer
192
+ * `projectId` + `status=open`; if that combined query is rejected, fall
193
+ * back to `projectId` alone.
194
+ */
195
+ async function listProjectThreads(token, companyUid, projectId) {
196
+ try {
197
+ return await listThreadsByQuery(token, {
198
+ companyUid,
199
+ projectId,
200
+ status: "open",
201
+ });
202
+ }
203
+ catch {
204
+ try {
205
+ return await listThreadsByQuery(token, { companyUid, projectId });
206
+ }
207
+ catch {
208
+ return [];
169
209
  }
170
210
  }
171
- return collected
172
- .filter((thread) => !projectId || thread.projectId === projectId)
173
- .sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
211
+ }
212
+ function mergeProjectAndStatusThreads(projectThreads, statusThreads, projectId) {
213
+ const seen = new Set();
214
+ const merged = [];
215
+ for (const thread of sortThreadsByActivity(projectThreads.filter((t) => (!t.projectId || t.projectId === projectId) && isActiveThread(t)))) {
216
+ const key = threadKey(thread);
217
+ if (key) {
218
+ if (seen.has(key))
219
+ continue;
220
+ seen.add(key);
221
+ }
222
+ merged.push(thread);
223
+ }
224
+ for (const thread of sortThreadsByActivity(statusThreads.filter((t) => t.projectId === projectId && isActiveThread(t)))) {
225
+ const key = threadKey(thread);
226
+ if (key) {
227
+ if (seen.has(key))
228
+ continue;
229
+ seen.add(key);
230
+ }
231
+ merged.push(thread);
232
+ }
233
+ return merged;
234
+ }
235
+ export async function listActiveThreads(token, companyUid, projectId) {
236
+ const statusListed = await listThreadsByStatus(token, companyUid);
237
+ if (!projectId) {
238
+ return sortThreadsByActivity(statusListed);
239
+ }
240
+ const projectListed = await listProjectThreads(token, companyUid, projectId);
241
+ return mergeProjectAndStatusThreads(projectListed, statusListed, projectId);
174
242
  }
175
243
  export function clamp(value, max) {
176
244
  return value.length <= max ? value : value.slice(0, max);
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
- * from GET /companies/{companyUid}/board at most every 60s.
3
+ * from the ProjectView read route at most every 60s.
4
+ *
5
+ * Board JSON remains the fallback for older projects, but story status is
6
+ * authoritative in ProjectView because `hq mesh story` writes there.
4
7
  * Metadata-only snapshot — never prompts or tokens.
5
8
  */
6
9
  export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
- * from GET /companies/{companyUid}/board at most every 60s.
3
+ * from the ProjectView read route at most every 60s.
4
+ *
5
+ * Board JSON remains the fallback for older projects, but story status is
6
+ * authoritative in ProjectView because `hq mesh story` writes there.
4
7
  * Metadata-only snapshot — never prompts or tokens.
5
8
  */
6
9
  import * as fs from "node:fs";
@@ -115,9 +118,72 @@ export async function refreshBoundSessionBoards(deps) {
115
118
  }
116
119
  return { refreshed, skipped, errors };
117
120
  }
121
+ function storiesFromProject(project) {
122
+ const storiesRaw = Array.isArray(project.stories) ? project.stories : [];
123
+ const stories = [];
124
+ for (const s of storiesRaw) {
125
+ if (!s || typeof s !== "object" || Array.isArray(s))
126
+ continue;
127
+ const row = s;
128
+ const id = typeof row.id === "string"
129
+ ? row.id
130
+ : typeof row.storyId === "string"
131
+ ? row.storyId
132
+ : typeof row.taskId === "string"
133
+ ? row.taskId
134
+ : "";
135
+ if (!id)
136
+ continue;
137
+ stories.push({
138
+ id,
139
+ title: typeof row.title === "string"
140
+ ? row.title.slice(0, 120)
141
+ : typeof row.name === "string"
142
+ ? row.name.slice(0, 120)
143
+ : undefined,
144
+ status: typeof row.status === "string" ? row.status : undefined,
145
+ });
146
+ }
147
+ return stories;
148
+ }
149
+ function projectViewSnapshot(raw, companyUid, projectId) {
150
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
151
+ return null;
152
+ const project = raw;
153
+ if (project.companyUid !== companyUid || project.projectId !== projectId)
154
+ return null;
155
+ return {
156
+ projectId,
157
+ projectName: typeof project.name === "string"
158
+ ? project.name
159
+ : typeof project.title === "string"
160
+ ? project.title
161
+ : undefined,
162
+ stories: storiesFromProject(project),
163
+ };
164
+ }
165
+ function isMissingProject(error) {
166
+ // hq-pro's GET ProjectView handler emits PROJECT_VIEW_NOT_FOUND; retain the
167
+ // legacy PROJECT_NOT_FOUND code for compatibility with older producers.
168
+ return (error instanceof Error &&
169
+ /^404\s+(?:PROJECT_VIEW_NOT_FOUND|PROJECT_NOT_FOUND)\b/.test(error.message));
170
+ }
118
171
  /** Build a BoardReader from meshJson-compatible fetch. */
119
172
  export function createVaultBoardReader(opts) {
120
173
  return async (companyUid, projectId) => {
174
+ // ProjectView is the live story-status source. Only use board.json when
175
+ // this project has not been migrated into ProjectView yet.
176
+ try {
177
+ const view = await opts.getJson(`/v1/work-mesh/projects/${encodeURIComponent(projectId)}?companyUid=${encodeURIComponent(companyUid)}`);
178
+ const snapshot = projectViewSnapshot(view, companyUid, projectId);
179
+ if (snapshot)
180
+ return snapshot;
181
+ throw new Error("ProjectView response did not match the requested company and project");
182
+ }
183
+ catch (error) {
184
+ if (!isMissingProject(error))
185
+ throw error;
186
+ }
121
187
  const raw = await opts.getJson(`/companies/${encodeURIComponent(companyUid)}/board`);
122
188
  if (!raw || typeof raw !== "object")
123
189
  return null;
@@ -140,35 +206,10 @@ export function createVaultBoardReader(opts) {
140
206
  const id = typeof p.id === "string" ? p.id : typeof p.projectId === "string" ? p.projectId : "";
141
207
  if (id !== projectId)
142
208
  continue;
143
- const storiesRaw = Array.isArray(p.stories)
144
- ? p.stories
145
- : Array.isArray(p.tasks)
146
- ? p.tasks
147
- : [];
148
- const stories = [];
149
- for (const s of storiesRaw) {
150
- if (!s || typeof s !== "object" || Array.isArray(s))
151
- continue;
152
- const row = s;
153
- const sid = typeof row.id === "string"
154
- ? row.id
155
- : typeof row.storyId === "string"
156
- ? row.storyId
157
- : typeof row.taskId === "string"
158
- ? row.taskId
159
- : "";
160
- if (!sid)
161
- continue;
162
- stories.push({
163
- id: sid,
164
- title: typeof row.title === "string"
165
- ? row.title.slice(0, 120)
166
- : typeof row.name === "string"
167
- ? row.name.slice(0, 120)
168
- : undefined,
169
- status: typeof row.status === "string" ? row.status : undefined,
170
- });
171
- }
209
+ const stories = storiesFromProject({
210
+ ...p,
211
+ stories: Array.isArray(p.stories) ? p.stories : p.tasks,
212
+ });
172
213
  return {
173
214
  projectId,
174
215
  projectName: typeof p.name === "string"