@indigoai-us/hq-cli 5.126.0 → 5.128.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 (78) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/core-utils/sentry-report.d.ts +2 -0
  17. package/dist/lib/core-utils/sentry-report.js +47 -7
  18. package/dist/lib/lanes/accounts.d.ts +67 -7
  19. package/dist/lib/lanes/accounts.js +233 -43
  20. package/dist/lib/lanes/compaction.d.ts +96 -0
  21. package/dist/lib/lanes/compaction.js +197 -0
  22. package/dist/lib/lanes/config.d.ts +15 -0
  23. package/dist/lib/lanes/config.js +65 -8
  24. package/dist/lib/lanes/delivery.d.ts +151 -0
  25. package/dist/lib/lanes/delivery.js +354 -0
  26. package/dist/lib/lanes/dropbox.d.ts +96 -0
  27. package/dist/lib/lanes/dropbox.js +408 -0
  28. package/dist/lib/lanes/effort.d.ts +50 -0
  29. package/dist/lib/lanes/effort.js +109 -0
  30. package/dist/lib/lanes/errors.d.ts +2 -2
  31. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  32. package/dist/lib/lanes/hooks-install.js +326 -0
  33. package/dist/lib/lanes/hq-root.d.ts +19 -0
  34. package/dist/lib/lanes/hq-root.js +28 -0
  35. package/dist/lib/lanes/index.d.ts +35 -5
  36. package/dist/lib/lanes/index.js +19 -4
  37. package/dist/lib/lanes/liveness.d.ts +7 -1
  38. package/dist/lib/lanes/liveness.js +64 -5
  39. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  40. package/dist/lib/lanes/machine-identity.js +92 -0
  41. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  42. package/dist/lib/lanes/mesh-events.js +275 -0
  43. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  44. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  45. package/dist/lib/lanes/questions.d.ts +75 -0
  46. package/dist/lib/lanes/questions.js +243 -0
  47. package/dist/lib/lanes/quota.d.ts +95 -0
  48. package/dist/lib/lanes/quota.js +320 -0
  49. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  50. package/dist/lib/lanes/reconcile-install.js +221 -0
  51. package/dist/lib/lanes/reconcile.d.ts +102 -0
  52. package/dist/lib/lanes/reconcile.js +985 -0
  53. package/dist/lib/lanes/registry.d.ts +18 -5
  54. package/dist/lib/lanes/registry.js +293 -19
  55. package/dist/lib/lanes/spawn.d.ts +274 -0
  56. package/dist/lib/lanes/spawn.js +1363 -0
  57. package/dist/lib/lanes/store.d.ts +7 -3
  58. package/dist/lib/lanes/store.js +150 -32
  59. package/dist/lib/lanes/types.d.ts +346 -4
  60. package/dist/lib/lanes/types.js +138 -5
  61. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  62. package/dist/lib/lanes/watcher-main.js +22 -0
  63. package/dist/lib/lanes/watcher.d.ts +216 -0
  64. package/dist/lib/lanes/watcher.js +859 -0
  65. package/dist/lib/mesh/api.d.ts +71 -7
  66. package/dist/lib/mesh/api.js +167 -28
  67. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  68. package/dist/lib/mesh/live/backfill-held.js +66 -21
  69. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/run.js +111 -54
  71. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  72. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  73. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  74. package/dist/lib/scan-packages/index.d.ts +2 -0
  75. package/dist/lib/scan-packages/index.js +1 -1
  76. package/dist/lib/work-context/state.d.ts +12 -0
  77. package/dist/lib/work-context/state.js +131 -3
  78. package/package.json +3 -1
@@ -2,20 +2,36 @@
2
2
  * Provider account resolution (spec §5.5).
3
3
  *
4
4
  * Naming an account and omitting one mean different things: an explicit
5
- * disabled id is refused; omitting rotates past a disabled default.
5
+ * disabled id is refused; omitting picks the least-loaded eligible account.
6
6
  * Status reports `configured` and `logged_in` separately. logged_in is
7
- * derived from whether <home>/auth.json EXISTS — this module never reads,
8
- * prints, copies, or hashes that file.
7
+ * derived from whether a provider credential file EXISTS — this module
8
+ * never reads, prints, copies, or hashes that file.
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { AccountResolutionError } from "./errors.js";
13
+ import { isTerminalLaneState } from "./types.js";
14
+ import { accountMaxRunning } from "./config.js";
15
+ import { listLaneIds, readLane } from "./store.js";
16
+ import { loadQuotaState, quotaBlockFor } from "./quota.js";
13
17
  export const PROVIDER_HOME_ENV = {
14
18
  claude: "CLAUDE_CONFIG_DIR",
15
19
  codex: "CODEX_HOME",
16
20
  grok: "GROK_HOME",
17
21
  };
18
22
  export const AUTH_JSON_NAME = "auth.json";
23
+ export const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
24
+ /**
25
+ * Credential filenames are a property of the provider. Codex and Grok write
26
+ * auth.json. Claude Code writes .credentials.json (never auth.json) inside
27
+ * the config dir, and can also authenticate via the OS keychain with no
28
+ * file here at all.
29
+ */
30
+ export const PROVIDER_CREDENTIAL_FILES = {
31
+ claude: [CLAUDE_CREDENTIALS_NAME],
32
+ codex: [AUTH_JSON_NAME],
33
+ grok: [AUTH_JSON_NAME],
34
+ };
19
35
  function defaultAccountDeps() {
20
36
  return { existsSync: (p) => fs.existsSync(p) };
21
37
  }
@@ -24,19 +40,213 @@ function enabledIds(provider) {
24
40
  .filter(([, account]) => account.enabled)
25
41
  .map(([id]) => id);
26
42
  }
27
- function rotateEnabled(providerCfg, skipId) {
28
- for (const [id, account] of Object.entries(providerCfg.accounts)) {
29
- if (!account.enabled)
43
+ export function credentialPaths(provider, home) {
44
+ return PROVIDER_CREDENTIAL_FILES[provider].map((name) => path.join(home, name));
45
+ }
46
+ /**
47
+ * Existence-only probe. Callers must not read the files this returns paths to.
48
+ */
49
+ export function hasCredentialFile(provider, home, existsSync) {
50
+ for (const filePath of credentialPaths(provider, home)) {
51
+ if (existsSync(filePath))
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * Claude with no file in the config dir is unknown, not logged out: the
58
+ * credential may live in the OS keychain or an API-key helper. Codex and
59
+ * Grok write auth.json when logged in, so absence is logged out.
60
+ */
61
+ export function authStateFor(provider, present) {
62
+ if (present)
63
+ return "file";
64
+ if (provider === "claude")
65
+ return "unknown";
66
+ return "absent";
67
+ }
68
+ export function loggedInFromAuthState(state) {
69
+ if (state === "file")
70
+ return true;
71
+ if (state === "absent")
72
+ return false;
73
+ return null;
74
+ }
75
+ /** Admission and listing share this: refuse only a conclusive logged-out. */
76
+ export function isLoggedOut(status) {
77
+ return status.logged_in === false;
78
+ }
79
+ /** Auto-select requires a proven credential file, not Claude-unknown. */
80
+ export function isCredentialProven(status) {
81
+ return status.logged_in === true;
82
+ }
83
+ /**
84
+ * Occupying states hold a slot: running / awaiting_input / blocked have a
85
+ * live (or paused-live) worker; queued holds a queue slot that will take a
86
+ * running slot. Terminal states do not count.
87
+ */
88
+ export function isOccupyingLaneState(state) {
89
+ return !isTerminalLaneState(state);
90
+ }
91
+ export function occupyingLaneCount(lanes, provider, accountId) {
92
+ let n = 0;
93
+ for (const lane of lanes) {
94
+ if (lane.provider !== provider)
30
95
  continue;
31
- if (skipId !== undefined && id === skipId)
96
+ if (lane.account !== accountId)
32
97
  continue;
33
- return id;
98
+ if (!isOccupyingLaneState(lane.state))
99
+ continue;
100
+ n += 1;
34
101
  }
102
+ return n;
103
+ }
104
+ export function runningLaneCount(lanes, provider, accountId) {
105
+ let n = 0;
106
+ for (const lane of lanes) {
107
+ if (lane.provider !== provider)
108
+ continue;
109
+ if (lane.account !== accountId)
110
+ continue;
111
+ if (lane.state !== "running")
112
+ continue;
113
+ n += 1;
114
+ }
115
+ return n;
116
+ }
117
+ /** Most recent created_at on this account+provider, any state. Null if none. */
118
+ export function lastStartedAt(lanes, provider, accountId) {
119
+ let latest = null;
120
+ for (const lane of lanes) {
121
+ if (lane.provider !== provider)
122
+ continue;
123
+ if (lane.account !== accountId)
124
+ continue;
125
+ if (latest === null || lane.created_at > latest)
126
+ latest = lane.created_at;
127
+ }
128
+ return latest;
129
+ }
130
+ function defaultLoadLanes(hqRoot) {
131
+ return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
132
+ }
133
+ function compareEligible(a, b) {
134
+ if (a.occupying !== b.occupying)
135
+ return a.occupying - b.occupying;
136
+ if (a.lastStarted === null && b.lastStarted === null) {
137
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
138
+ }
139
+ if (a.lastStarted === null)
140
+ return -1;
141
+ if (b.lastStarted === null)
142
+ return 1;
143
+ if (a.lastStarted !== b.lastStarted) {
144
+ return a.lastStarted < b.lastStarted ? -1 : 1;
145
+ }
146
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
147
+ }
148
+ function modelForAccount(provider, accountId, config, ctx) {
149
+ // Same order as resolveLaneModel: an explicit lane model, then --model,
150
+ // then the account default, then the provider default.
151
+ if (ctx.model !== undefined && ctx.model.trim().length > 0)
152
+ return ctx.model.trim();
153
+ if (ctx.cliModel !== undefined && ctx.cliModel.trim().length > 0)
154
+ return ctx.cliModel.trim();
155
+ const accountModel = config.providers[provider].accounts[accountId]?.model;
156
+ if (accountModel !== undefined && accountModel.trim().length > 0)
157
+ return accountModel.trim();
158
+ const providerModel = config.providers[provider].model;
159
+ if (providerModel !== undefined && providerModel.trim().length > 0)
160
+ return providerModel.trim();
35
161
  return undefined;
36
162
  }
37
- export function resolveAccount(provider, requestedId, config) {
163
+ function throwIfQuotaBlocked(block, provider, accountId) {
164
+ if (!block.blocked || !block.reason)
165
+ return;
166
+ const model = block.model ?? "(no model)";
167
+ if (block.reason === "account_model_unavailable") {
168
+ throw new AccountResolutionError("account_model_unavailable", `Account ${JSON.stringify(accountId)} cannot run model ${model}: ${block.marker ?? "needs credits"}`, {
169
+ provider,
170
+ account: accountId,
171
+ model: block.model ?? null,
172
+ marker: block.marker ?? null,
173
+ });
174
+ }
175
+ throw new AccountResolutionError("account_quota_exhausted", `Account ${JSON.stringify(accountId)} is out of quota${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""} for model ${model}`, {
176
+ provider,
177
+ account: accountId,
178
+ model: block.model ?? null,
179
+ quota_reset_at: block.quota_reset_at ?? null,
180
+ });
181
+ }
182
+ function boundAccount(provider, id, account) {
183
+ return {
184
+ provider,
185
+ id,
186
+ home: account.home,
187
+ enabled: true,
188
+ envVar: PROVIDER_HOME_ENV[provider],
189
+ };
190
+ }
191
+ /**
192
+ * Pick among enabled, credential-proven accounts by occupying count
193
+ * (lowest wins). Ties break to least-recently-started (oldest created_at),
194
+ * then account id ascending. Accounts at max_running are excluded from
195
+ * start-eligible; if every proven account is at cap the pick still returns
196
+ * one so admission can queue rather than overflow.
197
+ */
198
+ export function selectBalancedAccount(provider, config, ctx = {}) {
199
+ const providerCfg = config.providers[provider];
200
+ const existsSync = ctx.existsSync ?? ((p) => fs.existsSync(p));
201
+ const enabled = enabledIds(providerCfg);
202
+ if (enabled.length === 0) {
203
+ throw new AccountResolutionError("no_enabled_account", `No enabled account for provider ${provider}`, { provider, ineligible: "none_enabled" });
204
+ }
205
+ const proven = [];
206
+ for (const id of enabled) {
207
+ const account = providerCfg.accounts[id];
208
+ if (hasCredentialFile(provider, account.home, existsSync))
209
+ proven.push(id);
210
+ }
211
+ if (proven.length === 0) {
212
+ throw new AccountResolutionError("no_enabled_account", `No logged-in account for provider ${provider}`, { provider, ineligible: "none_logged_in", enabled });
213
+ }
214
+ const hqRoot = ctx.hqRoot;
215
+ const lanes = ctx.loadLanes !== undefined
216
+ ? ctx.loadLanes(hqRoot ?? "")
217
+ : hqRoot !== undefined
218
+ ? defaultLoadLanes(hqRoot)
219
+ : [];
220
+ const now = ctx.now ?? new Date();
221
+ const quota = hqRoot !== undefined ? loadQuotaState(hqRoot) : null;
222
+ const runnable = quota
223
+ ? proven.filter((id) => {
224
+ const block = quotaBlockFor(quota, provider, id, modelForAccount(provider, id, config, ctx), now);
225
+ return !block.blocked;
226
+ })
227
+ : proven;
228
+ if (quota && runnable.length === 0) {
229
+ const sample = proven[0];
230
+ const block = quotaBlockFor(quota, provider, sample, modelForAccount(provider, sample, config, ctx), now);
231
+ throwIfQuotaBlocked(block, provider, sample);
232
+ }
233
+ const underCap = runnable.filter((id) => {
234
+ const cap = accountMaxRunning(providerCfg, id);
235
+ return runningLaneCount(lanes, provider, id) < cap;
236
+ });
237
+ const pool = underCap.length > 0 ? underCap : runnable;
238
+ const ranked = pool
239
+ .map((id) => ({
240
+ id,
241
+ occupying: occupyingLaneCount(lanes, provider, id),
242
+ lastStarted: lastStartedAt(lanes, provider, id),
243
+ }))
244
+ .sort(compareEligible);
245
+ const chosen = ranked[0].id;
246
+ return boundAccount(provider, chosen, providerCfg.accounts[chosen]);
247
+ }
248
+ export function resolveAccount(provider, requestedId, config, ctx = {}) {
38
249
  const providerCfg = config.providers[provider];
39
- const envVar = PROVIDER_HOME_ENV[provider];
40
250
  const enabled = enabledIds(providerCfg);
41
251
  if (requestedId !== undefined && requestedId.length > 0) {
42
252
  const account = providerCfg.accounts[requestedId];
@@ -53,38 +263,16 @@ export function resolveAccount(provider, requestedId, config) {
53
263
  reason: account.disabled_reason ?? "",
54
264
  });
55
265
  }
56
- return {
57
- provider,
58
- id: requestedId,
59
- home: account.home,
60
- enabled: true,
61
- envVar,
62
- };
63
- }
64
- const defaultId = providerCfg.default_account;
65
- const defaultAccount = providerCfg.accounts[defaultId];
66
- if (defaultAccount?.enabled) {
67
- return {
68
- provider,
69
- id: defaultId,
70
- home: defaultAccount.home,
71
- enabled: true,
72
- envVar,
73
- };
74
- }
75
- const rotated = rotateEnabled(providerCfg, defaultAccount ? defaultId : undefined);
76
- if (rotated === undefined) {
77
- throw new AccountResolutionError("no_enabled_account", `No enabled account for provider ${provider}`, { provider });
266
+ if (ctx.hqRoot !== undefined) {
267
+ const now = ctx.now ?? new Date();
268
+ const block = quotaBlockFor(loadQuotaState(ctx.hqRoot), provider, requestedId, modelForAccount(provider, requestedId, config, ctx), now);
269
+ throwIfQuotaBlocked(block, provider, requestedId);
270
+ }
271
+ return boundAccount(provider, requestedId, account);
78
272
  }
79
- const rotatedAccount = providerCfg.accounts[rotated];
80
- return {
81
- provider,
82
- id: rotated,
83
- home: rotatedAccount.home,
84
- enabled: true,
85
- envVar,
86
- };
273
+ return selectBalancedAccount(provider, config, ctx);
87
274
  }
275
+ /** Codex/Grok path. Kept for callers that still name auth.json. */
88
276
  export function authJsonPath(home) {
89
277
  return path.join(home, AUTH_JSON_NAME);
90
278
  }
@@ -99,18 +287,20 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
99
287
  enabled: false,
100
288
  configured: false,
101
289
  logged_in: false,
290
+ auth_state: "absent",
102
291
  envVar,
103
292
  };
104
293
  }
105
- const authPath = authJsonPath(account.home);
106
- const logged_in = deps.existsSync(authPath);
294
+ const present = hasCredentialFile(provider, account.home, deps.existsSync);
295
+ const auth_state = authStateFor(provider, present);
107
296
  const status = {
108
297
  provider,
109
298
  id: accountId,
110
299
  home: account.home,
111
300
  enabled: account.enabled,
112
301
  configured: true,
113
- logged_in,
302
+ logged_in: loggedInFromAuthState(auth_state),
303
+ auth_state,
114
304
  envVar,
115
305
  };
116
306
  if (account.disabled_reason !== undefined) {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Automatic compaction of a resumed Claude lane.
3
+ *
4
+ * Owner directives 2026-09-22: "if it's been an hour since last msg, always
5
+ * run compact" and "there should be a way to run compact automatically using
6
+ * code written in hqcli".
7
+ *
8
+ * Every `--resume` replays the whole conversation. When a resume is due
9
+ * (`now - last_message_at >= compact_after_ms`), hq-cli first runs
10
+ * `claude -p --resume <sid> "/compact"` in the lane's account, then starts
11
+ * the resume as usual. The decision is made at resume time only; there is no
12
+ * timer.
13
+ *
14
+ * Measured 2026-09-22 on account d, claude-opus-5, effort low: a session
15
+ * with 71,853 cache-read tokens on a control turn was compacted in print
16
+ * mode. The `/compact` call's own JSON result reports `num_turns: 0` and
17
+ * zero usage, so it cannot be used as evidence. The session transcript
18
+ * gained a `compact_boundary` record (`trigger: manual`,
19
+ * `preTokens: 71899`), the session id was unchanged, and the next turn read
20
+ * 10,383 cache tokens plus 9,136 cache-creation tokens and still recalled a
21
+ * value from before the compaction. The transcript boundary is therefore
22
+ * the success signal this module checks.
23
+ *
24
+ * Nothing here logs the prompt, the summary or a response body. The
25
+ * transcript is read only for `compact_boundary` system records, and only
26
+ * their `compactMetadata.preTokens` is kept.
27
+ */
28
+ import type { LaneCompactionRecord, LaneRecord, LaneRunUsage } from "./types.js";
29
+ export declare const COMPACTION_MECHANISM: "claude_print_compact";
30
+ /** The whole prompt of the compaction turn. */
31
+ export declare const COMPACTION_PROMPT = "/compact";
32
+ /** Upper bound on the synchronous `/compact` call. */
33
+ export declare const COMPACTION_TIMEOUT_MS: number;
34
+ export interface CompactionDecision {
35
+ due: boolean;
36
+ reference_at: string;
37
+ elapsed_ms: number;
38
+ threshold_ms: number;
39
+ }
40
+ /**
41
+ * Whether a resume of this lane must compact first. The reference is the
42
+ * last accepted operator message, else the lane's creation (the brief is the
43
+ * first message). Only Claude lanes resume a session, so only they compact.
44
+ */
45
+ export declare function compactionDue(lane: Pick<LaneRecord, "provider" | "last_message_at" | "created_at">, nowMs: number, thresholdMs: number): CompactionDecision;
46
+ export interface CompactBoundaryProbe {
47
+ count: number;
48
+ last_pre_tokens: number | null;
49
+ }
50
+ /** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
51
+ export declare function findSessionTranscript(home: string, sessionId: string): string | null;
52
+ /** Count compact boundaries in a transcript. Other lines are never parsed. */
53
+ export declare function probeCompactBoundaries(transcriptPath: string | null): CompactBoundaryProbe;
54
+ export interface CompactionCommandResult {
55
+ status: number | null;
56
+ error?: string;
57
+ }
58
+ export type RunCompactionFn = (input: {
59
+ command: string;
60
+ args: string[];
61
+ env: NodeJS.ProcessEnv;
62
+ cwd: string;
63
+ stderrPath: string;
64
+ timeoutMs: number;
65
+ }) => CompactionCommandResult;
66
+ /** Synchronous `/compact`. stdout is discarded; stderr goes to the run dir. */
67
+ export declare const defaultRunCompaction: RunCompactionFn;
68
+ export interface CompactLaneSessionInput {
69
+ command: string;
70
+ /** Full argv of `claude -p --resume <sid> "/compact" …`, built by buildProviderCommand. */
71
+ args: string[];
72
+ sessionId: string;
73
+ accountHome: string;
74
+ env: NodeJS.ProcessEnv;
75
+ cwd: string;
76
+ logsDir: string;
77
+ decision: CompactionDecision;
78
+ before: LaneRunUsage | null;
79
+ now: () => Date;
80
+ run?: RunCompactionFn;
81
+ locateTranscript?: (home: string, sessionId: string) => string | null;
82
+ }
83
+ /**
84
+ * Run `/compact` on the recorded session and report what happened. A failed
85
+ * or ineffective compaction is recorded, not thrown: the resume that needed
86
+ * it still has a message to deliver.
87
+ */
88
+ export declare function compactLaneSession(input: CompactLaneSessionInput): LaneCompactionRecord;
89
+ /**
90
+ * Counters of the first run after the last compaction. Known once that run
91
+ * has exited and no later run has started: the watcher writes
92
+ * `last_run_usage` at exit, and `last_resume.at` still equals the
93
+ * compaction's `resume_at`.
94
+ */
95
+ export declare function compactionAfterUsage(lane: LaneRecord): LaneRunUsage | null;
96
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Automatic compaction of a resumed Claude lane.
3
+ *
4
+ * Owner directives 2026-09-22: "if it's been an hour since last msg, always
5
+ * run compact" and "there should be a way to run compact automatically using
6
+ * code written in hqcli".
7
+ *
8
+ * Every `--resume` replays the whole conversation. When a resume is due
9
+ * (`now - last_message_at >= compact_after_ms`), hq-cli first runs
10
+ * `claude -p --resume <sid> "/compact"` in the lane's account, then starts
11
+ * the resume as usual. The decision is made at resume time only; there is no
12
+ * timer.
13
+ *
14
+ * Measured 2026-09-22 on account d, claude-opus-5, effort low: a session
15
+ * with 71,853 cache-read tokens on a control turn was compacted in print
16
+ * mode. The `/compact` call's own JSON result reports `num_turns: 0` and
17
+ * zero usage, so it cannot be used as evidence. The session transcript
18
+ * gained a `compact_boundary` record (`trigger: manual`,
19
+ * `preTokens: 71899`), the session id was unchanged, and the next turn read
20
+ * 10,383 cache tokens plus 9,136 cache-creation tokens and still recalled a
21
+ * value from before the compaction. The transcript boundary is therefore
22
+ * the success signal this module checks.
23
+ *
24
+ * Nothing here logs the prompt, the summary or a response body. The
25
+ * transcript is read only for `compact_boundary` system records, and only
26
+ * their `compactMetadata.preTokens` is kept.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ export const COMPACTION_MECHANISM = "claude_print_compact";
32
+ /** The whole prompt of the compaction turn. */
33
+ export const COMPACTION_PROMPT = "/compact";
34
+ /** Upper bound on the synchronous `/compact` call. */
35
+ export const COMPACTION_TIMEOUT_MS = 10 * 60 * 1000;
36
+ /**
37
+ * Whether a resume of this lane must compact first. The reference is the
38
+ * last accepted operator message, else the lane's creation (the brief is the
39
+ * first message). Only Claude lanes resume a session, so only they compact.
40
+ */
41
+ export function compactionDue(lane, nowMs, thresholdMs) {
42
+ const reference_at = lane.last_message_at ?? lane.created_at;
43
+ const refMs = Date.parse(reference_at);
44
+ const elapsed_ms = Number.isFinite(refMs) ? nowMs - refMs : 0;
45
+ return {
46
+ due: lane.provider === "claude" && Number.isFinite(refMs) && elapsed_ms >= thresholdMs,
47
+ reference_at,
48
+ elapsed_ms,
49
+ threshold_ms: thresholdMs,
50
+ };
51
+ }
52
+ /** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
53
+ export function findSessionTranscript(home, sessionId) {
54
+ const projects = path.join(home, "projects");
55
+ let dirs;
56
+ try {
57
+ dirs = fs.readdirSync(projects);
58
+ }
59
+ catch (err) {
60
+ const code = err.code;
61
+ if (code === "ENOENT")
62
+ return null;
63
+ throw err;
64
+ }
65
+ for (const dir of dirs) {
66
+ const candidate = path.join(projects, dir, `${sessionId}.jsonl`);
67
+ if (fs.existsSync(candidate))
68
+ return candidate;
69
+ }
70
+ return null;
71
+ }
72
+ /** Count compact boundaries in a transcript. Other lines are never parsed. */
73
+ export function probeCompactBoundaries(transcriptPath) {
74
+ if (transcriptPath === null)
75
+ return { count: 0, last_pre_tokens: null };
76
+ let text;
77
+ try {
78
+ text = fs.readFileSync(transcriptPath, "utf8");
79
+ }
80
+ catch (err) {
81
+ const code = err.code;
82
+ if (code === "ENOENT")
83
+ return { count: 0, last_pre_tokens: null };
84
+ throw err;
85
+ }
86
+ let count = 0;
87
+ let last = null;
88
+ for (const line of text.split("\n")) {
89
+ if (!line.includes('"compact_boundary"'))
90
+ continue;
91
+ let rec;
92
+ try {
93
+ rec = JSON.parse(line);
94
+ }
95
+ catch (err) {
96
+ process.stderr.write(`hq lanes: skipped an unparseable line in ${transcriptPath}: ${err instanceof Error ? err.message : String(err)}\n`);
97
+ continue;
98
+ }
99
+ if (rec.type !== "system" || rec.subtype !== "compact_boundary")
100
+ continue;
101
+ count += 1;
102
+ const meta = rec.compactMetadata;
103
+ const pre = meta?.preTokens;
104
+ last = typeof pre === "number" && Number.isFinite(pre) ? pre : null;
105
+ }
106
+ return { count, last_pre_tokens: last };
107
+ }
108
+ /** Synchronous `/compact`. stdout is discarded; stderr goes to the run dir. */
109
+ export const defaultRunCompaction = (input) => {
110
+ fs.mkdirSync(path.dirname(input.stderrPath), { recursive: true });
111
+ const errFd = fs.openSync(input.stderrPath, "a");
112
+ try {
113
+ const result = spawnSync(input.command, input.args, {
114
+ cwd: input.cwd,
115
+ env: input.env,
116
+ stdio: ["ignore", "ignore", errFd],
117
+ timeout: input.timeoutMs,
118
+ });
119
+ if (result.error) {
120
+ return { status: result.status, error: result.error.message };
121
+ }
122
+ return { status: result.status };
123
+ }
124
+ finally {
125
+ fs.closeSync(errFd);
126
+ }
127
+ };
128
+ /**
129
+ * Run `/compact` on the recorded session and report what happened. A failed
130
+ * or ineffective compaction is recorded, not thrown: the resume that needed
131
+ * it still has a message to deliver.
132
+ */
133
+ export function compactLaneSession(input) {
134
+ const run = input.run ?? defaultRunCompaction;
135
+ const locate = input.locateTranscript ?? findSessionTranscript;
136
+ const started = input.now();
137
+ const beforeProbe = probeCompactBoundaries(locate(input.accountHome, input.sessionId));
138
+ const result = run({
139
+ command: input.command,
140
+ args: input.args,
141
+ env: input.env,
142
+ cwd: input.cwd,
143
+ stderrPath: path.join(input.logsDir, "compact.err"),
144
+ timeoutMs: COMPACTION_TIMEOUT_MS,
145
+ });
146
+ const afterProbe = probeCompactBoundaries(locate(input.accountHome, input.sessionId));
147
+ const duration_ms = input.now().getTime() - started.getTime();
148
+ let outcome;
149
+ let reason;
150
+ if (afterProbe.count > beforeProbe.count) {
151
+ outcome = "compacted";
152
+ }
153
+ else if (result.status !== 0 || result.error !== undefined) {
154
+ outcome = "failed";
155
+ reason = result.error ?? `exit status ${String(result.status)}`;
156
+ }
157
+ else {
158
+ outcome = "no_effect";
159
+ reason = "the session transcript gained no compact_boundary record";
160
+ }
161
+ if (outcome !== "compacted") {
162
+ process.stderr.write(`hq lanes: compaction of session ${input.sessionId} ${outcome}: ${reason ?? ""}\n`);
163
+ }
164
+ return {
165
+ at: started.toISOString(),
166
+ mechanism: COMPACTION_MECHANISM,
167
+ session_id: input.sessionId,
168
+ outcome,
169
+ ...(reason !== undefined ? { reason } : {}),
170
+ duration_ms,
171
+ elapsed_ms: input.decision.elapsed_ms,
172
+ reference_at: input.decision.reference_at,
173
+ pre_tokens: outcome === "compacted" ? afterProbe.last_pre_tokens : null,
174
+ before: input.before,
175
+ after: null,
176
+ };
177
+ }
178
+ /**
179
+ * Counters of the first run after the last compaction. Known once that run
180
+ * has exited and no later run has started: the watcher writes
181
+ * `last_run_usage` at exit, and `last_resume.at` still equals the
182
+ * compaction's `resume_at`.
183
+ */
184
+ export function compactionAfterUsage(lane) {
185
+ const c = lane.last_compaction;
186
+ if (c === undefined)
187
+ return null;
188
+ if (c.after !== null)
189
+ return c.after;
190
+ if (c.resume_at === undefined || lane.state === "running" || lane.state === "queued") {
191
+ return null;
192
+ }
193
+ if (lane.last_resume?.at !== c.resume_at)
194
+ return null;
195
+ return lane.last_run_usage ?? null;
196
+ }
197
+ //# sourceMappingURL=compaction.js.map
@@ -13,6 +13,17 @@ export declare const DEFAULT_MAX_QUEUED = 3;
13
13
  export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
14
14
  export declare const DEFAULT_CPU_BUSY_RESUME = 0.7;
15
15
  export declare const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
16
+ /**
17
+ * Owner directive 2026-09-22: "if it's been an hour since last msg, always
18
+ * run compact". Override per provider with `compact_after_ms`.
19
+ */
20
+ export declare const DEFAULT_COMPACT_AFTER_MS: number;
21
+ /**
22
+ * Watcher `lane_progress` throttle: at most one thread event per five minutes
23
+ * per lane, and only when the tick saw new activity. Override per provider
24
+ * with `progress_event_min_interval_ms`.
25
+ */
26
+ export declare const DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS: number;
16
27
  /** Spec §9: last event within 10 minutes → live. */
17
28
  export declare const DEFAULT_LIVE_WITHIN_MS: number;
18
29
  /** Spec §9: turn_start with no turn_end after 2 hours → lost. */
@@ -51,6 +62,10 @@ export interface ConfigWriteDeps {
51
62
  }
52
63
  /** Persist operator config. Homes are stored as already-canonicalised paths. */
53
64
  export declare function writeLanesConfig(hqRoot: string, config: LanesConfig, deps?: ConfigWriteDeps): void;
65
+ /** Resume-time compaction threshold for a provider, defaulting to one hour. */
66
+ export declare function providerCompactAfterMs(provider: ProviderConfig): number;
67
+ /** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
68
+ export declare function providerProgressEventMinIntervalMs(provider: ProviderConfig): number;
54
69
  /** Account running cap, defaulting to the provider ceiling when unset. */
55
70
  export declare function accountMaxRunning(provider: ProviderConfig, accountId: string): number;
56
71
  //# sourceMappingURL=config.d.ts.map