@indigoai-us/hq-cli 5.122.2 → 5.123.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +59 -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/dm.d.ts +54 -1
  6. package/dist/commands/dm.js +204 -8
  7. package/dist/commands/mesh.js +7 -2
  8. package/dist/commands/update.js +6 -0
  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/mesh/api.js +96 -28
  14. package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
  15. package/dist/lib/mesh/live/backfill-held.js +323 -42
  16. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
  17. package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
  18. package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
  19. package/dist/lib/mesh/live/daemon/config.js +45 -0
  20. package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
  21. package/dist/lib/mesh/live/daemon/credentials.js +20 -4
  22. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  23. package/dist/lib/mesh/live/daemon/doctor.js +29 -2
  24. package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
  25. package/dist/lib/mesh/live/daemon/index.js +3 -2
  26. package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
  27. package/dist/lib/mesh/live/daemon/install.js +54 -4
  28. package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
  29. package/dist/lib/mesh/live/daemon/run.js +50 -3
  30. package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
  31. package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
  32. package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
  33. package/dist/lib/mesh/live/paths.d.ts +2 -0
  34. package/dist/lib/mesh/live/paths.js +4 -0
  35. package/dist/lib/work-context/reconcile.js +67 -6
  36. package/dist/lib/work-context/state.d.ts +5 -3
  37. package/dist/lib/work-context/state.js +28 -4
  38. package/dist/utils/cli-telemetry.d.ts +21 -0
  39. package/dist/utils/cli-telemetry.js +77 -1
  40. package/dist/utils/self-update.d.ts +3 -0
  41. package/dist/utils/self-update.js +11 -0
  42. package/package.json +3 -2
@@ -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"
@@ -0,0 +1,9 @@
1
+ export interface DaemonConfig {
2
+ trustedHqRoots: string[];
3
+ }
4
+ export declare function daemonConfigPath(daemonDirectory: string): string;
5
+ export declare function verifyHqRoot(candidate: string | undefined): string | null;
6
+ export declare function readDaemonConfig(daemonDirectory: string): DaemonConfig;
7
+ /** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
8
+ export declare function registerTrustedHqRoot(daemonDirectory: string, candidate: string | undefined): DaemonConfig;
9
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,45 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ export function daemonConfigPath(daemonDirectory) {
4
+ return path.join(daemonDirectory, "config.json");
5
+ }
6
+ export function verifyHqRoot(candidate) {
7
+ if (!candidate?.trim())
8
+ return null;
9
+ try {
10
+ const root = fs.realpathSync(candidate);
11
+ return fs.statSync(path.join(root, "companies", "manifest.yaml")).isFile()
12
+ ? root
13
+ : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function readDaemonConfig(daemonDirectory) {
20
+ try {
21
+ const raw = JSON.parse(fs.readFileSync(daemonConfigPath(daemonDirectory), "utf8"));
22
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
23
+ return { trustedHqRoots: [] };
24
+ const roots = raw.trustedHqRoots;
25
+ if (!Array.isArray(roots))
26
+ return { trustedHqRoots: [] };
27
+ return { trustedHqRoots: roots.filter((root) => typeof root === "string")
28
+ .map(verifyHqRoot).filter((root) => Boolean(root)) };
29
+ }
30
+ catch {
31
+ return { trustedHqRoots: [] };
32
+ }
33
+ }
34
+ /** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
35
+ export function registerTrustedHqRoot(daemonDirectory, candidate) {
36
+ const root = verifyHqRoot(candidate);
37
+ const current = readDaemonConfig(daemonDirectory);
38
+ if (!root || current.trustedHqRoots.includes(root))
39
+ return current;
40
+ const next = { trustedHqRoots: [...current.trustedHqRoots, root] };
41
+ fs.mkdirSync(daemonDirectory, { recursive: true, mode: 0o700 });
42
+ fs.writeFileSync(daemonConfigPath(daemonDirectory), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
43
+ return next;
44
+ }
45
+ //# sourceMappingURL=config.js.map
@@ -49,6 +49,11 @@ export interface PresenceCompany {
49
49
  companyUid: string;
50
50
  presenceTopic: string;
51
51
  }
52
+ export interface DroppedPresenceCompany {
53
+ companyUid: string;
54
+ /** Server-supplied cause when available; otherwise a deliberately neutral label. */
55
+ reason: string;
56
+ }
52
57
  export interface Contract3Bundle {
53
58
  contractVersion: 3;
54
59
  credentials: IotCredentials;
@@ -58,7 +63,7 @@ export interface Contract3Bundle {
58
63
  actorUid: string;
59
64
  actorType: "human" | "agent";
60
65
  companies: PresenceCompany[];
61
- droppedCompanies: string[];
66
+ droppedCompanies: DroppedPresenceCompany[];
62
67
  expiresAt: string;
63
68
  }
64
69
  export type CredentialsFetcher = () => Promise<Contract3Bundle>;
@@ -229,9 +229,25 @@ export function normalizeContract3Bundle(raw) {
229
229
  if (!expiresAt) {
230
230
  throw new Error("contract-3 vend missing expiresAt");
231
231
  }
232
- const dropped = Array.isArray(r.droppedCompanies)
233
- ? r.droppedCompanies.filter((x) => typeof x === "string")
234
- : [];
232
+ const droppedCompanies = [];
233
+ for (const item of Array.isArray(r.droppedCompanies) ? r.droppedCompanies : []) {
234
+ if (typeof item === "string" && item.trim()) {
235
+ droppedCompanies.push({ companyUid: item, reason: "unknown" });
236
+ continue;
237
+ }
238
+ if (!item || typeof item !== "object" || Array.isArray(item))
239
+ continue;
240
+ const row = item;
241
+ const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
242
+ if (!companyUid)
243
+ continue;
244
+ droppedCompanies.push({
245
+ companyUid,
246
+ reason: typeof row.reason === "string" && row.reason.trim()
247
+ ? row.reason.trim()
248
+ : "not included",
249
+ });
250
+ }
235
251
  return {
236
252
  contractVersion: 3,
237
253
  credentials: {
@@ -246,7 +262,7 @@ export function normalizeContract3Bundle(raw) {
246
262
  actorUid,
247
263
  actorType,
248
264
  companies,
249
- droppedCompanies: dropped,
265
+ droppedCompanies,
250
266
  expiresAt,
251
267
  };
252
268
  }
@@ -17,6 +17,8 @@ export interface DaemonDoctorReport {
17
17
  subscribed: boolean;
18
18
  spoolDepth: number;
19
19
  heldCount: number;
20
+ /** Distinct held sessions with neither bound state nor local company evidence. */
21
+ heldWithNoLocalEvidence: number;
20
22
  deadLetterCount: number;
21
23
  /** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
22
24
  unattributedEvents: UnattributedEventCounts;
@@ -32,6 +34,8 @@ export interface DaemonDoctorReport {
32
34
  actorKind: CognitoActorKind;
33
35
  /** Credential vend refused (disabled / unsupported / 403). */
34
36
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
37
+ /** Companies the current presence credential did not include. */
38
+ presenceDroppedCompanies: NonNullable<DaemonStateFile["presenceDroppedCompanies"]>;
35
39
  /** Pending events awaiting a retry drain (POST /v1/mesh/events). */
36
40
  emitRetryDepth: number;
37
41
  /** Last successful direct-emit POST time. */
@@ -48,6 +52,8 @@ export interface DaemonDoctorDeps {
48
52
  home?: string;
49
53
  meshRoot?: string;
50
54
  workContextRoot?: string;
55
+ /** Independently configured HQ root for held-event evidence checks. */
56
+ hqRoot?: string;
51
57
  env?: NodeJS.ProcessEnv;
52
58
  isProcessAlive?: (pid: number) => boolean;
53
59
  now?: () => number;
@@ -5,13 +5,16 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
- import { outboxStats, } from "../../../work-context/outbox.js";
8
+ import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
9
+ import { readSessionState } from "../../../work-context/state.js";
9
10
  import { workContextRoot } from "../../../work-context/paths.js";
10
11
  import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
11
12
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
12
13
  import { daemonDir, daemonPidPath } from "./paths.js";
13
14
  import { readEmitState } from "../emit.js";
14
15
  import { emitRetryDepth } from "../emit-retry.js";
16
+ import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompanyEvidence, } from "../backfill-held.js";
17
+ import { readDaemonConfig } from "./config.js";
15
18
  import { pidLockStatus, readPidLock } from "./pid-lock.js";
16
19
  import { readDaemonState } from "./state.js";
17
20
  export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
@@ -77,6 +80,14 @@ export function collectDaemonDoctor(deps = {}) {
77
80
  const deadPath = workMeshDeadLetterPath(meshRoot);
78
81
  const spoolDepth = countJsonlLines(spoolPath);
79
82
  const heldCount = countJsonlLines(heldPath);
83
+ const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
84
+ const outboxOperations = listOutboxOperations(ctxRoot);
85
+ const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
86
+ const state = readSessionState(ref.sessionId, ctxRoot);
87
+ return (!state?.companyUid &&
88
+ !hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
89
+ !refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
90
+ }).length;
80
91
  const deadLetterCount = countJsonlLines(deadPath);
81
92
  const unattributedEvents = countUnattributedEvents(meshRoot);
82
93
  const outbox = outboxStats(ctxRoot);
@@ -100,11 +111,18 @@ export function collectDaemonDoctor(deps = {}) {
100
111
  if (emitMode === "legacy" && heldCount > 0) {
101
112
  unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
102
113
  }
114
+ if (heldWithNoLocalEvidence > 0) {
115
+ unhealthyReasons.push(`held with no local evidence=${heldWithNoLocalEvidence}`);
116
+ }
103
117
  if (unattributedEvents.total > 0) {
104
118
  unhealthyReasons.push(`unattributed events=${unattributedEvents.total} ` +
105
119
  `(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
106
120
  }
107
121
  const presenceRefusal = state?.presenceRefusal ?? null;
122
+ const presenceDroppedCompanies = state?.presenceDroppedCompanies ?? [];
123
+ if (presenceDroppedCompanies.length > 0) {
124
+ unhealthyReasons.push(`presence unavailable for ${presenceDroppedCompanies.length} companies`);
125
+ }
108
126
  if (presenceRefusal) {
109
127
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
110
128
  }
@@ -121,6 +139,7 @@ export function collectDaemonDoctor(deps = {}) {
121
139
  companiesOnline,
122
140
  spoolDepth,
123
141
  heldCount,
142
+ heldWithNoLocalEvidence,
124
143
  deadLetterCount,
125
144
  unattributedEvents,
126
145
  outboxDepth: outbox.depth,
@@ -132,6 +151,7 @@ export function collectDaemonDoctor(deps = {}) {
132
151
  tokenSource: auth.tokenSource,
133
152
  actorKind: auth.actorKind,
134
153
  presenceRefusal,
154
+ presenceDroppedCompanies,
135
155
  emitMode,
136
156
  subscribed,
137
157
  emitRetryDepth: emitRetry,
@@ -157,7 +177,7 @@ export function formatDaemonDoctor(report) {
157
177
  if (report.emitMode === "legacy") {
158
178
  // Legacy emit path only: the spool/held/outbox queues do not exist in
159
179
  // receive-only (direct) mode.
160
- lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
180
+ lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
161
181
  }
162
182
  lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
163
183
  `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
@@ -174,6 +194,13 @@ export function formatDaemonDoctor(report) {
174
194
  if (report.presenceRefusal) {
175
195
  lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
176
196
  }
197
+ if (report.presenceDroppedCompanies.length > 0) {
198
+ lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
199
+ report.presenceDroppedCompanies
200
+ .map((company) => `${company.companyUid} (${company.reason})`)
201
+ .join(", ") +
202
+ `; presence credential holds ${report.companiesOnline.length} companies; these were not included`);
203
+ }
177
204
  if (report.unhealthy) {
178
205
  lines.push(`health: UNHEALTHY`);
179
206
  for (const reason of report.unhealthyReasons) {
@@ -12,12 +12,14 @@ export type { DaemonStateFile, PresenceRefusalState } from "./state.js";
12
12
  export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
13
13
  export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
14
14
  export type { BoardReader, BoardRefreshDeps, BoardStorySnapshot } from "./board-refresh.js";
15
- export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
15
+ export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
16
16
  export type { DaemonInstallPlatform, InstallPaths, InstallResult, } from "./install.js";
17
+ export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
18
+ export type { DaemonConfig } from "./config.js";
17
19
  export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
18
20
  export type { DaemonDoctorDeps, DaemonDoctorReport } from "./doctor.js";
19
21
  export { scopeBundleToAgentIdentity, } from "./credentials.js";
20
- export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
22
+ export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
21
23
  export type { DaemonHandle, DaemonRunDeps } from "./run.js";
22
24
  export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
23
25
  export type { DiscoveredTranscript, DiscoverScanMeta, DiscoverTranscriptsResult, TranscriptDiscoverCache, TranscriptFs, TranscriptKind, TranscriptWatchDeps, TranscriptWatchTickResult, } from "./transcript-watch.js";
@@ -6,9 +6,10 @@ export { CLOSE_LOG_ALWAYS_COUNT, CLOSE_LOG_INTERVAL_MS, CONNECT_STABLE_GRACE_MS,
6
6
  export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
7
7
  export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
8
8
  export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
9
- export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
9
+ export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
10
+ export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
10
11
  export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
11
12
  export { scopeBundleToAgentIdentity, } from "./credentials.js";
12
- export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
13
+ export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
13
14
  export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
14
15
  //# sourceMappingURL=index.js.map
@@ -23,6 +23,8 @@ export interface InstallPaths {
23
23
  systemdUnitPath: string;
24
24
  /** When true, render the root/ec2-user system unit instead of user unit. */
25
25
  systemUser?: string;
26
+ /** Verified HQ root to persist and pass to the installed service. */
27
+ hqRoot?: string;
26
28
  }
27
29
  export interface InstallResult {
28
30
  platform: DaemonInstallPlatform;
@@ -33,6 +35,26 @@ export interface InstallResult {
33
35
  running?: boolean;
34
36
  message: string;
35
37
  }
38
+ /** Best-effort restart result used after an in-place CLI upgrade. */
39
+ export interface RestartDaemonResult {
40
+ attempted: boolean;
41
+ restarted: boolean;
42
+ message?: string;
43
+ }
44
+ /**
45
+ * Restart the installed user daemon after a CLI upgrade. This is deliberately
46
+ * best-effort: an update must remain successful if a user has no service,
47
+ * launchctl/systemd is unavailable, or the user manager is not running.
48
+ */
49
+ export declare function restartDaemonServiceIfInstalled(opts?: {
50
+ platform?: DaemonInstallPlatform;
51
+ home?: string;
52
+ uid?: number;
53
+ existsSync?: typeof fs.existsSync;
54
+ run?: (command: string, args: string[]) => {
55
+ status: number | null;
56
+ };
57
+ }): RestartDaemonResult;
36
58
  export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstallPlatform;
37
59
  /**
38
60
  * HQ updates moved the CLI from toolchain/node/bin/hq to
@@ -53,6 +75,7 @@ export declare function buildInstallPaths(opts: {
53
75
  env?: NodeJS.ProcessEnv;
54
76
  argv?: string[];
55
77
  execPath?: string;
78
+ hqRoot?: string;
56
79
  }): InstallPaths;
57
80
  export declare function renderLaunchdPlist(paths: InstallPaths): string;
58
81
  export declare function renderSystemdUserUnit(paths: InstallPaths): string;
@@ -70,6 +93,10 @@ export declare function installDaemonService(opts: {
70
93
  mkdirSync?: typeof fs.mkdirSync;
71
94
  /** Optional: actually load the service (disabled in tests). */
72
95
  activate?: (platform: DaemonInstallPlatform, dest: string) => void;
96
+ /** Reload a rewritten user unit so a legacy Restart=on-failure unit migrates immediately. */
97
+ reloadSystemd?: () => {
98
+ status: number | null;
99
+ };
73
100
  }): InstallResult;
74
101
  export declare function uninstallDaemonService(opts: {
75
102
  platform?: DaemonInstallPlatform;
@@ -12,8 +12,54 @@
12
12
  import * as fs from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import * as path from "node:path";
15
+ import { spawnSync } from "node:child_process";
15
16
  import { resolveDaemonAssetDir } from "./log.js";
16
17
  import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, } from "./paths.js";
18
+ import { registerTrustedHqRoot } from "./config.js";
19
+ /**
20
+ * Restart the installed user daemon after a CLI upgrade. This is deliberately
21
+ * best-effort: an update must remain successful if a user has no service,
22
+ * launchctl/systemd is unavailable, or the user manager is not running.
23
+ */
24
+ export function restartDaemonServiceIfInstalled(opts = {}) {
25
+ const platform = opts.platform ?? detectPlatform();
26
+ const home = opts.home ?? os.homedir();
27
+ const existsSync = opts.existsSync ?? fs.existsSync;
28
+ const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
29
+ try {
30
+ if (platform === "darwin") {
31
+ const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
32
+ if (!existsSync(plist))
33
+ return { attempted: false, restarted: false };
34
+ const uid = opts.uid ?? process.getuid?.();
35
+ if (typeof uid !== "number")
36
+ return { attempted: false, restarted: false };
37
+ const result = run("launchctl", ["kickstart", "-k", `gui/${uid}/${LAUNCHD_LABEL}`]);
38
+ return { attempted: true, restarted: result.status === 0 };
39
+ }
40
+ if (platform === "linux") {
41
+ const unit = path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME);
42
+ if (existsSync(unit)) {
43
+ const result = run("systemctl", ["--user", "restart", SYSTEMD_UNIT_NAME]);
44
+ return { attempted: true, restarted: result.status === 0 };
45
+ }
46
+ const systemUnit = path.join("/etc", "systemd", "system", SYSTEMD_UNIT_NAME);
47
+ if (existsSync(systemUnit)) {
48
+ return {
49
+ attempted: true,
50
+ restarted: false,
51
+ message: "system unit detected; restart requires the box's service manager",
52
+ };
53
+ }
54
+ return { attempted: false, restarted: false };
55
+ }
56
+ }
57
+ catch {
58
+ // The caller has already completed the update; service restart failure is
59
+ // intentionally non-fatal and will be retried by normal service recovery.
60
+ }
61
+ return { attempted: false, restarted: false };
62
+ }
17
63
  export function detectPlatform(platform = process.platform) {
18
64
  if (platform === "darwin")
19
65
  return "darwin";
@@ -83,6 +129,7 @@ export function buildInstallPaths(opts) {
83
129
  systemdUserDir: path.join(home, ".config", "systemd", "user"),
84
130
  systemdUnitPath: path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME),
85
131
  systemUser: opts.systemUser,
132
+ hqRoot: opts.hqRoot,
86
133
  };
87
134
  }
88
135
  export function renderLaunchdPlist(paths) {
@@ -116,6 +163,7 @@ export function renderLaunchdPlist(paths) {
116
163
  <string>${escapeXml(paths.home)}</string>
117
164
  <key>PATH</key>
118
165
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
166
+ ${paths.hqRoot ? `<key>HQ_ROOT</key>\n <string>${escapeXml(paths.hqRoot)}</string>` : ""}
119
167
  </dict>
120
168
  </dict>
121
169
  </plist>
@@ -131,11 +179,11 @@ Wants=network-online.target
131
179
  Type=simple
132
180
  ExecStart=${shellQuote(paths.nodeBinary)} ${shellQuote(paths.hqBinary)} mesh daemon run
133
181
  WorkingDirectory=${paths.daemonDir}
134
- Restart=on-failure
182
+ Restart=always
135
183
  RestartSec=5
136
184
  Environment=HOME=${paths.home}
137
185
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
138
- StandardOutput=append:${paths.logPath}
186
+ ${paths.hqRoot ? `Environment=HQ_ROOT=${shellQuote(paths.hqRoot)}\n` : ""}StandardOutput=append:${paths.logPath}
139
187
  StandardError=append:${paths.logPath}
140
188
 
141
189
  [Install]
@@ -163,13 +211,13 @@ User=${user}
163
211
  Group=${user}
164
212
  ExecStart=${shellQuote(paths.nodeBinary)} ${shellQuote(paths.hqBinary)} mesh daemon run
165
213
  WorkingDirectory=/home/${user}/.hq/work-mesh/daemon
166
- Restart=on-failure
214
+ Restart=always
167
215
  RestartSec=5
168
216
  Environment=HOME=/home/${user}
169
217
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
170
218
  Environment=HQ_MACHINE_CREDS_FILE=/home/${user}/.hq-agent/machine-creds.json
171
219
  Environment=HQ_AGENT_IDENTITY_FILE=/var/lib/hq-agent/identity.json
172
- StandardOutput=append:/home/${user}/.hq/work-mesh/daemon/log
220
+ ${paths.hqRoot ? `Environment=HQ_ROOT=${shellQuote(paths.hqRoot)}\n` : ""}StandardOutput=append:/home/${user}/.hq/work-mesh/daemon/log
173
221
  StandardError=append:/home/${user}/.hq/work-mesh/daemon/log
174
222
 
175
223
  [Install]
@@ -195,6 +243,7 @@ export function installDaemonService(opts) {
195
243
  const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
196
244
  const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
197
245
  mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
246
+ registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
198
247
  if (platform === "darwin") {
199
248
  const rendered = renderLaunchdPlist(paths);
200
249
  mkdirSync(paths.launchAgentsDir, { recursive: true });
@@ -232,6 +281,7 @@ export function installDaemonService(opts) {
232
281
  const rendered = renderSystemdUserUnit(paths);
233
282
  mkdirSync(paths.systemdUserDir, { recursive: true });
234
283
  writeFileSync(paths.systemdUnitPath, rendered, { mode: 0o600 });
284
+ (opts.reloadSystemd ?? (() => spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" })))();
235
285
  opts.activate?.(platform, paths.systemdUnitPath);
236
286
  return {
237
287
  platform,
@@ -19,6 +19,8 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
19
19
  export declare const FLUSH_INTERVAL_MS = 10000;
20
20
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
21
21
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
22
+ /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
23
+ export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
22
24
  /**
23
25
  * Only producer appends to spool.jsonl should wake the watcher. Every other
24
26
  * file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
@@ -80,6 +82,10 @@ export interface DaemonRunDeps {
80
82
  log?: (dir: string, line: string) => void;
81
83
  /** Stop after this many ms (tests). */
82
84
  runForMs?: number;
85
+ /** CLI version loaded by this daemon. Defaults to this package's version. */
86
+ daemonVersion?: string;
87
+ /** Read the currently installed CLI version behind the launch entrypoint. */
88
+ readInstalledCliVersion?: () => string | null;
83
89
  random?: () => number;
84
90
  now?: () => Date;
85
91
  }