@indigoai-us/hq-cli 5.123.0 → 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.
@@ -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
@@ -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;
@@ -50,6 +52,8 @@ export interface DaemonDoctorDeps {
50
52
  home?: string;
51
53
  meshRoot?: string;
52
54
  workContextRoot?: string;
55
+ /** Independently configured HQ root for held-event evidence checks. */
56
+ hqRoot?: string;
53
57
  env?: NodeJS.ProcessEnv;
54
58
  isProcessAlive?: (pid: number) => boolean;
55
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,6 +111,9 @@ 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})`);
@@ -125,6 +139,7 @@ export function collectDaemonDoctor(deps = {}) {
125
139
  companiesOnline,
126
140
  spoolDepth,
127
141
  heldCount,
142
+ heldWithNoLocalEvidence,
128
143
  deadLetterCount,
129
144
  unattributedEvents,
130
145
  outboxDepth: outbox.depth,
@@ -162,7 +177,7 @@ export function formatDaemonDoctor(report) {
162
177
  if (report.emitMode === "legacy") {
163
178
  // Legacy emit path only: the spool/held/outbox queues do not exist in
164
179
  // receive-only (direct) mode.
165
- 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}`);
166
181
  }
167
182
  lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
168
183
  `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
@@ -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
  }
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
+ import { CLI_VERSION } from "../../../../cli-version.js";
14
15
  import { createWorkSessionDeliverer } from "../../client.js";
15
16
  import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
16
17
  import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
@@ -31,10 +32,14 @@ import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state
31
32
  import { workMeshHeldPath } from "../paths.js";
32
33
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
33
34
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
35
+ import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
36
+ import { readDaemonConfig } from "./config.js";
34
37
  export const SPOOL_DEBOUNCE_MS = 2_000;
35
38
  export const FLUSH_INTERVAL_MS = 10_000;
36
39
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
37
40
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
41
+ /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
42
+ export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
38
43
  /**
39
44
  * Only producer appends to spool.jsonl should wake the watcher. Every other
40
45
  * file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
@@ -76,7 +81,15 @@ export async function runMeshDaemon(deps = {}) {
76
81
  ((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
77
82
  const processEvents = deps.processEvents ?? process;
78
83
  const exit = deps.exit ?? ((code) => process.exit(code));
84
+ const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
85
+ const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
79
86
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
88
+ const trustedRoots = [...new Set([
89
+ ...(deps.hqRoot ? [deps.hqRoot] : []),
90
+ ...(env.HQ_ROOT?.trim() ? [env.HQ_ROOT.trim()] : []),
91
+ ...configuredRoots,
92
+ ])];
80
93
  ensureDaemonLog(dir);
81
94
  const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
82
95
  const lock = acquirePidLock(dir, lockDeps);
@@ -132,6 +145,7 @@ export async function runMeshDaemon(deps = {}) {
132
145
  return backfillHeldSessions({
133
146
  workMeshRoot: meshRoot,
134
147
  workContextRoot: ctxRoot,
148
+ hqRoots: trustedRoots,
135
149
  limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
136
150
  env,
137
151
  reconcile: (observation) => reconcileObservation(observation, {
@@ -223,15 +237,28 @@ export async function runMeshDaemon(deps = {}) {
223
237
  }
224
238
  }
225
239
  let stopped = false;
240
+ let restartRequested = false;
226
241
  let flushInFlight = null;
227
242
  let debounceHandle = null;
228
243
  let flushIntervalHandle = null;
244
+ let versionIntervalHandle = null;
229
245
  let boardIntervalHandle = null;
230
246
  let transcriptIntervalHandle = null;
231
247
  let watcher = null;
232
248
  /** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
233
249
  const lastHookEventAt = new Map();
234
250
  let transcriptWatcher = null;
251
+ const requestRestartForVersionDrift = () => {
252
+ if (stopped || restartRequested)
253
+ return restartRequested;
254
+ const installedVersion = installedCliVersion();
255
+ if (!hasCliVersionDrift(daemonVersion, installedVersion))
256
+ return false;
257
+ restartRequested = true;
258
+ log(dir, `cli upgraded ${daemonVersion} -> ${installedVersion}, restarting`);
259
+ exit(DAEMON_VERSION_DRIFT_EXIT_CODE);
260
+ return true;
261
+ };
235
262
  if (emitPath && deps.enableTranscriptWatch !== false) {
236
263
  const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
237
264
  const getToken = deps.getToken ?? defaultGetToken;
@@ -273,7 +300,12 @@ export async function runMeshDaemon(deps = {}) {
273
300
  }
274
301
  const doFlushCycle = async (reason, opts = {}) => {
275
302
  // Refuse new cycles after stop, except the forced shutdown flush.
276
- if (stopped && !opts.force)
303
+ if ((stopped || restartRequested) && !opts.force)
304
+ return;
305
+ // A long-lived daemon retains its loaded JS after a global CLI upgrade.
306
+ // The service unit resolves the binary again on restart, so exiting lets
307
+ // launchd/systemd bring up the upgraded implementation.
308
+ if (!opts.force && requestRestartForVersionDrift())
277
309
  return;
278
310
  if (flushInFlight) {
279
311
  await flushInFlight;
@@ -405,6 +437,14 @@ export async function runMeshDaemon(deps = {}) {
405
437
  boardIntervalHandle = timers.setTimeout(boardTick, BOARD_REFRESH_INTERVAL_MS);
406
438
  });
407
439
  }, BOARD_REFRESH_INTERVAL_MS);
440
+ // Version drift is independent of the legacy spool flush. Direct-mode
441
+ // daemons have no flush cycle, so they still need the same periodic restart
442
+ // check after an in-place CLI upgrade.
443
+ versionIntervalHandle = timers.setTimeout(function versionTick() {
444
+ if (stopped || requestRestartForVersionDrift())
445
+ return;
446
+ versionIntervalHandle = timers.setTimeout(versionTick, FLUSH_INTERVAL_MS);
447
+ }, FLUSH_INTERVAL_MS);
408
448
  const runTranscriptTick = async () => {
409
449
  if (!transcriptWatcher || stopped)
410
450
  return;
@@ -447,7 +487,7 @@ export async function runMeshDaemon(deps = {}) {
447
487
  }
448
488
  // Initial pass. Board refresh runs in both modes; the flush + transcript
449
489
  // scan are legacy emit-path only.
450
- if (emitPath) {
490
+ if (!requestRestartForVersionDrift() && emitPath) {
451
491
  void doFlushCycle("start");
452
492
  }
453
493
  void refreshFn().catch(() => undefined);
@@ -463,6 +503,8 @@ export async function runMeshDaemon(deps = {}) {
463
503
  timers.clearTimeout(debounceHandle);
464
504
  if (flushIntervalHandle !== null)
465
505
  timers.clearTimeout(flushIntervalHandle);
506
+ if (versionIntervalHandle !== null)
507
+ timers.clearTimeout(versionIntervalHandle);
466
508
  if (boardIntervalHandle !== null)
467
509
  timers.clearTimeout(boardIntervalHandle);
468
510
  if (transcriptIntervalHandle !== null) {
@@ -0,0 +1,5 @@
1
+ /** Version installed behind the entrypoint that launched this daemon. */
2
+ export declare function readInstalledCliVersion(entry?: string): string | null;
3
+ /** A missing/unreadable installed version is not proof of a safe restart. */
4
+ export declare function hasCliVersionDrift(daemonVersion: string, installedVersion: string | null | undefined): installedVersion is string;
5
+ //# sourceMappingURL=version-drift.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Read the version of the CLI package currently selected by the daemon's
3
+ * launch entrypoint. The daemon keeps the version it was loaded with in
4
+ * memory; an npm/pnpm upgrade can repoint this entrypoint without restarting
5
+ * that process.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ function packageVersionAtOrAbove(entry) {
10
+ let dir;
11
+ try {
12
+ dir = path.dirname(fs.realpathSync(entry));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ // A CLI entrypoint is normally <package>/dist/index.js. Keep the walk
18
+ // bounded so a malformed entrypoint never traverses an entire filesystem.
19
+ for (let i = 0; i < 8; i += 1) {
20
+ const pkgPath = path.join(dir, "package.json");
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
23
+ if (parsed.name === "@indigoai-us/hq-cli" &&
24
+ typeof parsed.version === "string" &&
25
+ parsed.version.trim()) {
26
+ return parsed.version.trim();
27
+ }
28
+ }
29
+ catch {
30
+ // No package manifest at this level; continue walking upward.
31
+ }
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir)
34
+ break;
35
+ dir = parent;
36
+ }
37
+ return null;
38
+ }
39
+ /** Version installed behind the entrypoint that launched this daemon. */
40
+ export function readInstalledCliVersion(entry = process.argv[1]) {
41
+ return entry ? packageVersionAtOrAbove(entry) : null;
42
+ }
43
+ /** A missing/unreadable installed version is not proof of a safe restart. */
44
+ export function hasCliVersionDrift(daemonVersion, installedVersion) {
45
+ return Boolean(installedVersion && installedVersion !== daemonVersion);
46
+ }
47
+ //# sourceMappingURL=version-drift.js.map
@@ -6,6 +6,8 @@ export declare const WORK_MESH_DIRNAME = "work-mesh";
6
6
  export declare function workMeshRoot(home?: string, env?: NodeJS.ProcessEnv): string;
7
7
  export declare function workMeshSpoolPath(root?: string): string;
8
8
  export declare function workMeshHeldPath(root?: string): string;
9
+ /** Rotating cursor for bounded daemon-start held-event recovery. */
10
+ export declare function workMeshHeldBackfillCursorPath(root?: string): string;
9
11
  export declare function workMeshDeadLetterPath(root?: string): string;
10
12
  export declare function workMeshDroppedReceiptPath(root?: string): string;
11
13
  /** Claim path: spool.<ts>.claimed (never reuse spool.jsonl for claim). */
@@ -17,6 +17,10 @@ export function workMeshSpoolPath(root) {
17
17
  export function workMeshHeldPath(root) {
18
18
  return path.join(root ?? workMeshRoot(), "held.jsonl");
19
19
  }
20
+ /** Rotating cursor for bounded daemon-start held-event recovery. */
21
+ export function workMeshHeldBackfillCursorPath(root) {
22
+ return path.join(root ?? workMeshRoot(), "held-backfill-cursor.json");
23
+ }
20
24
  export function workMeshDeadLetterPath(root) {
21
25
  return path.join(root ?? workMeshRoot(), "dead-letter.jsonl");
22
26
  }
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
10
10
  import { decisionFromCandidates, } from "./organize.js";
11
11
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
12
12
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
13
- import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
13
+ import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
14
14
  import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
15
15
  function resultOf(parts) {
16
16
  const out = {
@@ -293,12 +293,10 @@ export async function reconcileObservation(obs, deps) {
293
293
  startedAt,
294
294
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
295
295
  ...(hasTurn ? { hasTurn: true } : {}),
296
- // Preserve prior binding ids for visibility; do not auto-switch.
296
+ // Preserve prior scope for visibility; its receipt belongs to the
297
+ // quarantined binding and must not suppress a later retry.
297
298
  ...(prior?.projectId ? { projectId: prior.projectId } : {}),
298
299
  ...(prior?.taskId ? { taskId: prior.taskId } : {}),
299
- ...(prior?.bindingEpisodeId
300
- ? { bindingEpisodeId: prior.bindingEpisodeId }
301
- : {}),
302
300
  ...transcriptFields,
303
301
  decision: {
304
302
  decisionId: `conflict_${sessionId}`,
@@ -325,7 +323,7 @@ export async function reconcileObservation(obs, deps) {
325
323
  askAfter: false,
326
324
  ...(correctionPath ? { correctionPath } : {}),
327
325
  },
328
- }, prior), deps.root);
326
+ }, prior, { preserveBindingEpisode: false }), deps.root);
329
327
  return {
330
328
  result: resultOf({
331
329
  kind: "company_conflict",
@@ -491,6 +489,69 @@ export async function reconcileObservation(obs, deps) {
491
489
  prior.bindingEpisodeId
492
490
  ? "needs_project"
493
491
  : registerClassification;
492
+ // Re-observing an already acknowledged binding is normal for hooks and the
493
+ // resident daemon. A fresh clientOperationId must not turn that observation
494
+ // into a second semantic register operation: the prior receipt is the stable
495
+ // identity for this exact company/project/task binding.
496
+ const acknowledgedPrior = prior?.bindingEpisodeId &&
497
+ prior.companyUid === resolvedCompany.uid &&
498
+ prior.companySlug === resolvedCompany.slug &&
499
+ prior.projectId === projectId &&
500
+ prior.taskId === taskId &&
501
+ listOutboxOperations(deps.root).some((operation) => operation.kind === "register" &&
502
+ operation.delivery === "acked" &&
503
+ operation.sessionId === sessionId &&
504
+ operation.receiptId === prior.bindingEpisodeId &&
505
+ operation.companyUid === resolvedCompany.uid &&
506
+ operation.companySlug === resolvedCompany.slug &&
507
+ operation.projectId === projectId &&
508
+ operation.taskId === taskId)
509
+ ? prior
510
+ : null;
511
+ if (acknowledgedPrior) {
512
+ // Activity is local progress, not binding identity. Retain the receipt
513
+ // without another register while carrying hook counters forward so the
514
+ // normal clarification threshold still advances.
515
+ writeSessionState(mergeLocalSessionFields({
516
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
517
+ sessionId,
518
+ companyUid: acknowledgedPrior.companyUid,
519
+ companySlug: acknowledgedPrior.companySlug,
520
+ contextStatus: stateClassification,
521
+ projectId,
522
+ taskId,
523
+ bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
524
+ updatedAt: nowIso,
525
+ startedAt,
526
+ ...(typeof toolWrites === "number" ? { toolWrites } : {}),
527
+ ...(hasTurn ? { hasTurn: true } : {}),
528
+ ...(decision ? { decision } : {}),
529
+ ...transcriptFields,
530
+ }, prior), deps.root);
531
+ const kind = registerClassification === "bound"
532
+ ? "bound"
533
+ : registerClassification === "needs_task"
534
+ ? "needs_task"
535
+ : registerClassification === "needs_project"
536
+ ? "needs_project"
537
+ : "queued";
538
+ diagnostics.push(`binding unchanged; retained receipt=${acknowledgedPrior.bindingEpisodeId}`);
539
+ return {
540
+ result: resultOf({
541
+ kind,
542
+ classification: registerClassification,
543
+ delivery: "acked",
544
+ sessionId,
545
+ clientOperationId: obs.clientOperationId,
546
+ companyUid: resolvedCompany.uid,
547
+ projectId,
548
+ taskId,
549
+ bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
550
+ }),
551
+ exitCode: EXIT_OK,
552
+ diagnostics,
553
+ };
554
+ }
494
555
  let outboxOp;
495
556
  try {
496
557
  outboxOp = enqueueOutbox({