@indigoai-us/hq-cli 5.126.0 → 5.127.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 (76) hide show
  1. package/CHANGELOG.md +211 -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/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import { parseConfigEffort } from "./effort.js";
12
13
  import { LanesConfigError } from "./errors.js";
13
14
  import { CONFIG_SCHEMA_VERSION, LANE_PROVIDERS, isLaneProvider, parseMajorSchema, } from "./types.js";
14
15
  export const LANES_CONFIG_REL = path.join("personal", "lanes", "config.json");
@@ -16,6 +17,17 @@ export const DEFAULT_MAX_QUEUED = 3;
16
17
  export const DEFAULT_CPU_BUSY_PAUSE = 0.85;
17
18
  export const DEFAULT_CPU_BUSY_RESUME = 0.7;
18
19
  export const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
20
+ /**
21
+ * Owner directive 2026-09-22: "if it's been an hour since last msg, always
22
+ * run compact". Override per provider with `compact_after_ms`.
23
+ */
24
+ export const DEFAULT_COMPACT_AFTER_MS = 60 * 60 * 1000;
25
+ /**
26
+ * Watcher `lane_progress` throttle: at most one thread event per five minutes
27
+ * per lane, and only when the tick saw new activity. Override per provider
28
+ * with `progress_event_min_interval_ms`.
29
+ */
30
+ export const DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS = 5 * 60 * 1000;
19
31
  /** Spec §9: last event within 10 minutes → live. */
20
32
  export const DEFAULT_LIVE_WITHIN_MS = 10 * 60 * 1000;
21
33
  /** Spec §9: turn_start with no turn_end after 2 hours → lost. */
@@ -131,8 +143,20 @@ function parseAccount(provider, id, raw, deps) {
131
143
  if (raw.max_running !== undefined) {
132
144
  account.max_running = positiveInt(raw.max_running, `providers.${provider}.accounts.${id}.max_running`);
133
145
  }
146
+ if (raw.model !== undefined) {
147
+ account.model = nonEmptyModel(raw.model, `providers.${provider}.accounts.${id}.model`);
148
+ }
149
+ if (raw.effort !== undefined) {
150
+ account.effort = parseConfigEffort(provider, raw.effort, `providers.${provider}.accounts.${id}.effort`);
151
+ }
134
152
  return account;
135
153
  }
154
+ function nonEmptyModel(value, field) {
155
+ if (typeof value !== "string" || value.trim().length === 0) {
156
+ throw new LanesConfigError("invalid_config", `${field} must be a non-empty string`, { field, value });
157
+ }
158
+ return value.trim();
159
+ }
136
160
  function rejectDuplicateHomes(provider, accounts) {
137
161
  const seen = new Map();
138
162
  for (const [id, account] of Object.entries(accounts)) {
@@ -176,18 +200,38 @@ function parseProvider(provider, raw, deps) {
176
200
  }
177
201
  }
178
202
  rejectDuplicateHomes(provider, accounts);
179
- const defaultAccount = typeof raw.default_account === "string" && raw.default_account.length > 0
180
- ? raw.default_account
181
- : "a";
182
- if (Object.keys(accounts).length > 0 && accounts[defaultAccount] === undefined) {
183
- throw new LanesConfigError("invalid_config", `providers.${provider}.default_account ${JSON.stringify(defaultAccount)} is not a configured account`, { provider, default_account: defaultAccount, accounts: Object.keys(accounts) });
203
+ // default_account is accepted so existing files parse, and ignored for
204
+ // selection. An omitted field is not an error.
205
+ let defaultAccount;
206
+ if (raw.default_account !== undefined) {
207
+ if (typeof raw.default_account !== "string" || raw.default_account.length === 0) {
208
+ throw new LanesConfigError("invalid_config", `providers.${provider}.default_account must be a non-empty string`, { provider, field: "default_account" });
209
+ }
210
+ defaultAccount = raw.default_account;
211
+ if (Object.keys(accounts).length > 0 && accounts[defaultAccount] === undefined) {
212
+ throw new LanesConfigError("invalid_config", `providers.${provider}.default_account ${JSON.stringify(defaultAccount)} is not a configured account`, { provider, default_account: defaultAccount, accounts: Object.keys(accounts) });
213
+ }
184
214
  }
185
- return {
215
+ const parsed = {
186
216
  max_running: positiveInt(raw.max_running, `providers.${provider}.max_running`, DEFAULT_PROVIDER_MAX_RUNNING[provider]),
187
217
  max_queued: positiveInt(raw.max_queued, `providers.${provider}.max_queued`, DEFAULT_MAX_QUEUED),
188
- default_account: defaultAccount,
189
218
  accounts,
190
219
  };
220
+ if (defaultAccount !== undefined)
221
+ parsed.default_account = defaultAccount;
222
+ if (raw.model !== undefined) {
223
+ parsed.model = nonEmptyModel(raw.model, `providers.${provider}.model`);
224
+ }
225
+ if (raw.effort !== undefined) {
226
+ parsed.effort = parseConfigEffort(provider, raw.effort, `providers.${provider}.effort`);
227
+ }
228
+ if (raw.compact_after_ms !== undefined) {
229
+ parsed.compact_after_ms = positiveInt(raw.compact_after_ms, `providers.${provider}.compact_after_ms`);
230
+ }
231
+ if (raw.progress_event_min_interval_ms !== undefined) {
232
+ parsed.progress_event_min_interval_ms = positiveInt(raw.progress_event_min_interval_ms, `providers.${provider}.progress_event_min_interval_ms`);
233
+ }
234
+ return parsed;
191
235
  }
192
236
  function parseThrottle(raw) {
193
237
  const obj = raw === undefined ? {} : raw;
@@ -231,7 +275,6 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
231
275
  providers[provider] = {
232
276
  max_running: DEFAULT_PROVIDER_MAX_RUNNING[provider],
233
277
  max_queued: DEFAULT_MAX_QUEUED,
234
- default_account: "a",
235
278
  accounts: defaultAccountMap(provider, deps),
236
279
  };
237
280
  }
@@ -263,6 +306,12 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
263
306
  throw new LanesConfigError("unknown_provider", `Unknown provider(s) ${unknown.join(", ")}. Accepted: ${LANE_PROVIDERS.join(", ")}`, { accepted: [...LANE_PROVIDERS], value: unknown });
264
307
  }
265
308
  }
309
+ // Spec §5.5: a missing *file* yields the documented defaults, and the
310
+ // enable switch — not omission — is how an operator turns an account off.
311
+ // The provider set is fixed at {claude, codex, grok} (§5), so a present
312
+ // config that names only `grok` still receives the documented claude and
313
+ // codex defaults (including real-home `~/.claude` / `~/.codex`). Omitting
314
+ // a provider key is not a disable.
266
315
  const providers = {};
267
316
  for (const provider of LANE_PROVIDERS) {
268
317
  const entry = isPlainObject(providersRaw) ? providersRaw[provider] : undefined;
@@ -341,6 +390,14 @@ export function writeLanesConfig(hqRoot, config, deps = defaultWriteDeps()) {
341
390
  throw err;
342
391
  }
343
392
  }
393
+ /** Resume-time compaction threshold for a provider, defaulting to one hour. */
394
+ export function providerCompactAfterMs(provider) {
395
+ return provider.compact_after_ms ?? DEFAULT_COMPACT_AFTER_MS;
396
+ }
397
+ /** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
398
+ export function providerProgressEventMinIntervalMs(provider) {
399
+ return provider.progress_event_min_interval_ms ?? DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS;
400
+ }
344
401
  /** Account running cap, defaulting to the provider ceiling when unset. */
345
402
  export function accountMaxRunning(provider, accountId) {
346
403
  const account = provider.accounts[accountId];
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Follow-up delivery decision, per provider and per lane state, in one place.
3
+ *
4
+ * Two mechanisms exist. A live provider process drains the drop box on its
5
+ * next `PreToolUse` event (dropbox.ts). A Claude process is print-and-exit,
6
+ * so after it exits the only way to deliver is `claude -p --resume <sid>`,
7
+ * which continues the recorded session and keeps its prompt cache. Codex
8
+ * (0.155.1) has `codex exec resume <thread-id>`; the id is the `thread_id`
9
+ * of the `thread.started` event on the `--json` stdout stream, and the same
10
+ * id comes back on every resumed turn. Grok's CLI (1.0.34) has
11
+ * `--resume <id>` and its `--single --output-format json` result carries a
12
+ * `sessionId`, but no live run has shown `--single --resume` continue a
13
+ * session (the grok account was out of balance when this was written), so
14
+ * an exited grok lane keeps no delivery path until that is proven.
15
+ *
16
+ * Every dispatch site (`hq lanes message`, spawn, reconcile resume) asks this
17
+ * module rather than testing provider or state inline. A guard bound at one
18
+ * site and not another is the defect class this branch has already shipped.
19
+ */
20
+ import { type LaneProvider, type LaneRecord, type LaneState } from "./types.js";
21
+ /** Which providers can continue an exited worker from a recorded session id. */
22
+ export declare const PROVIDER_SESSION_RESUME: Record<LaneProvider, boolean>;
23
+ export declare function providerSupportsSessionResume(provider: LaneProvider): boolean;
24
+ /**
25
+ * States an operator message may resume from, given a session id.
26
+ *
27
+ * `done`: the worker finished its brief, wrote a done envelope and exited on
28
+ * its own. Nothing about the lane says the operator is finished with it; a
29
+ * follow-up is the normal next step, and the session is intact.
30
+ *
31
+ * `blocked`: the worker exited after writing a blocked envelope that names
32
+ * what it needs. An operator message is usually that missing input. The state
33
+ * is not terminal (spec §6.7) and nothing stopped the lane deliberately.
34
+ *
35
+ * Refused by `hq lanes message`:
36
+ * `awaiting_input` has its own path, `hq lanes questions answer`, and the
37
+ * reconcile pass resumes it once every question is answered. `killed` and
38
+ * `cancelled` were stopped on purpose by the operator; a message must not
39
+ * silently restart them. `failed` means the run ended without an envelope or
40
+ * never started; the operator has not seen why. `interrupted` exited with
41
+ * partial output and no envelope, so the lane's state was never closed
42
+ * cleanly; only the explicit `hq lanes resume` opt-in continues it
43
+ * (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
44
+ * resume.
45
+ */
46
+ export declare const RESUMABLE_LANE_STATES: readonly ["done", "blocked"];
47
+ export type ResumableLaneState = (typeof RESUMABLE_LANE_STATES)[number];
48
+ export declare function isResumableLaneState(state: LaneState): state is ResumableLaneState;
49
+ /**
50
+ * States `hq lanes resume` continues from. `interrupted` is the one a plain
51
+ * message refuses: the run ended without closing its state, and continuing
52
+ * it is a decision the operator makes by name. `done` and `blocked` are here
53
+ * so the one command covers every exited-with-a-session case.
54
+ */
55
+ export declare const RESUME_COMMAND_STATES: readonly ["interrupted", "done", "blocked"];
56
+ export type ResumeCommandState = (typeof RESUME_COMMAND_STATES)[number];
57
+ export declare function isResumeCommandState(state: LaneState): state is ResumeCommandState;
58
+ export declare const LANE_DELIVERY_MECHANISMS: readonly ["dropbox", "resume", "none"];
59
+ export type LaneDeliveryMechanism = (typeof LANE_DELIVERY_MECHANISMS)[number];
60
+ export type DeliveryLane = Pick<LaneRecord, "state" | "provider" | "provider_session_id">;
61
+ /**
62
+ * `dropbox`: a process is (or is about to be) alive to drain the inbox.
63
+ * `resume`: the process has exited, the provider can resume, the state allows
64
+ * it, and a session id was recorded from the worker's own result.
65
+ * `none`: nothing can deliver; the caller must refuse and write nothing.
66
+ */
67
+ export declare function laneDeliveryMechanism(lane: DeliveryLane): LaneDeliveryMechanism;
68
+ export declare function hasRecordedSessionId(lane: Pick<LaneRecord, "provider_session_id">): lane is Pick<LaneRecord, "provider_session_id"> & {
69
+ provider_session_id: string;
70
+ };
71
+ export declare function deliveryRefusalReason(lane: DeliveryLane): string;
72
+ /** Reasons a resumable provider's exit recorded no session id. Never a fabricated value. */
73
+ export declare const SESSION_ID_ABSENT_REASONS: readonly ["worker_out_missing", "no_result_json", "no_session_id_field", "no_thread_started_event"];
74
+ export type SessionIdAbsentReason = (typeof SESSION_ID_ABSENT_REASONS)[number];
75
+ export interface WorkerRunResult {
76
+ session_id: string | null;
77
+ session_id_absent: SessionIdAbsentReason | null;
78
+ cache_read_input_tokens: number | null;
79
+ cache_creation_input_tokens: number | null;
80
+ }
81
+ /**
82
+ * Parse the `claude -p --output-format json` result. stdout is appended
83
+ * across runs of the same lane, so the last `type: "result"` object is this
84
+ * run's. Only `session_id` and the two cache counters are read out; the
85
+ * prompt, the response body and everything else in the payload stay on disk.
86
+ */
87
+ export declare function parseClaudeResultOutput(text: string): WorkerRunResult;
88
+ /**
89
+ * Parse the `codex exec --json` event stream. The first event of a run is
90
+ * `{"type":"thread.started","thread_id":"<uuid>"}`; that id is what
91
+ * `codex exec resume <id>` takes, and a resumed run emits the same id again.
92
+ * Usage comes from the last `turn.completed` event: `cached_input_tokens`
93
+ * is the cache read and `cache_write_input_tokens` the cache creation.
94
+ * Nothing else on the stream is read out.
95
+ */
96
+ export declare function parseCodexEventOutput(text: string): WorkerRunResult;
97
+ /** Per-provider stdout parser; null for a provider whose exit carries nothing to record. */
98
+ export declare function parseWorkerRunOutput(provider: LaneProvider, text: string): WorkerRunResult | null;
99
+ /**
100
+ * Read this run's result from the worker's own stdout. stdout is opened in
101
+ * append mode across runs, so only bytes past `workerOutOffset` (recorded in
102
+ * spawn.json at launch) are this run's. Returns null for a provider whose
103
+ * exit carries nothing to record, so those lanes keep behaving exactly as
104
+ * before.
105
+ */
106
+ export declare function readWorkerRunResult(runDir: string, provider: LaneProvider,
107
+ /** Bytes of worker.out that belong to earlier runs; parsing starts after them. */
108
+ workerOutOffset?: number): WorkerRunResult | null;
109
+ export declare const LAUNCH_TRIGGERS: readonly ["admit", "answered_questions", "message", "resume"];
110
+ export type LaunchTriggerKind = (typeof LAUNCH_TRIGGERS)[number];
111
+ export type LaunchTrigger = {
112
+ kind: "admit";
113
+ } | {
114
+ kind: "answered_questions";
115
+ } | {
116
+ kind: "message";
117
+ text: string;
118
+ message_id: string;
119
+ } | {
120
+ /** `hq lanes resume`: the operator's explicit opt-in to continue an exited lane. */
121
+ kind: "resume";
122
+ /** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
123
+ from: ResumeCommandState;
124
+ text?: string;
125
+ message_id?: string;
126
+ };
127
+ export type LaunchPlan = {
128
+ mode: "fresh";
129
+ } | {
130
+ mode: "resume";
131
+ session_id: string;
132
+ };
133
+ /**
134
+ * Refusal for `hq lanes resume`, in precedence order: wrong state, provider
135
+ * without a resume path, no recorded session. Null when the resume can go.
136
+ */
137
+ export declare function resumeCommandRefusal(lane: DeliveryLane): {
138
+ error: "lane_not_resumable" | "resume_unsupported" | "no_provider_session";
139
+ message: string;
140
+ } | null;
141
+ /**
142
+ * Decide fresh-vs-resume for a spawn. Admission always starts fresh with the
143
+ * brief. Answered questions resume the recorded session when the provider
144
+ * can, else start fresh with the answers file (today's behaviour). An operator
145
+ * message can only be a resume, and only when `laneDeliveryMechanism` says so.
146
+ * `hq lanes resume` is also always a resume, gated by `resumeCommandRefusal`.
147
+ */
148
+ export declare function planLaneLaunch(lane: DeliveryLane, trigger: LaunchTrigger): LaunchPlan;
149
+ /** Lane states a launch may claim `running` from, per trigger. */
150
+ export declare function claimableStatesFor(trigger: LaunchTrigger): readonly LaneState[];
151
+ //# sourceMappingURL=delivery.d.ts.map
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Follow-up delivery decision, per provider and per lane state, in one place.
3
+ *
4
+ * Two mechanisms exist. A live provider process drains the drop box on its
5
+ * next `PreToolUse` event (dropbox.ts). A Claude process is print-and-exit,
6
+ * so after it exits the only way to deliver is `claude -p --resume <sid>`,
7
+ * which continues the recorded session and keeps its prompt cache. Codex
8
+ * (0.155.1) has `codex exec resume <thread-id>`; the id is the `thread_id`
9
+ * of the `thread.started` event on the `--json` stdout stream, and the same
10
+ * id comes back on every resumed turn. Grok's CLI (1.0.34) has
11
+ * `--resume <id>` and its `--single --output-format json` result carries a
12
+ * `sessionId`, but no live run has shown `--single --resume` continue a
13
+ * session (the grok account was out of balance when this was written), so
14
+ * an exited grok lane keeps no delivery path until that is proven.
15
+ *
16
+ * Every dispatch site (`hq lanes message`, spawn, reconcile resume) asks this
17
+ * module rather than testing provider or state inline. A guard bound at one
18
+ * site and not another is the defect class this branch has already shipped.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { LanesError } from "./errors.js";
23
+ import { parseLaneProvider, } from "./types.js";
24
+ /** Which providers can continue an exited worker from a recorded session id. */
25
+ export const PROVIDER_SESSION_RESUME = {
26
+ claude: true,
27
+ codex: true,
28
+ grok: false,
29
+ };
30
+ export function providerSupportsSessionResume(provider) {
31
+ return PROVIDER_SESSION_RESUME[parseLaneProvider(provider)];
32
+ }
33
+ /**
34
+ * States an operator message may resume from, given a session id.
35
+ *
36
+ * `done`: the worker finished its brief, wrote a done envelope and exited on
37
+ * its own. Nothing about the lane says the operator is finished with it; a
38
+ * follow-up is the normal next step, and the session is intact.
39
+ *
40
+ * `blocked`: the worker exited after writing a blocked envelope that names
41
+ * what it needs. An operator message is usually that missing input. The state
42
+ * is not terminal (spec §6.7) and nothing stopped the lane deliberately.
43
+ *
44
+ * Refused by `hq lanes message`:
45
+ * `awaiting_input` has its own path, `hq lanes questions answer`, and the
46
+ * reconcile pass resumes it once every question is answered. `killed` and
47
+ * `cancelled` were stopped on purpose by the operator; a message must not
48
+ * silently restart them. `failed` means the run ended without an envelope or
49
+ * never started; the operator has not seen why. `interrupted` exited with
50
+ * partial output and no envelope, so the lane's state was never closed
51
+ * cleanly; only the explicit `hq lanes resume` opt-in continues it
52
+ * (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
53
+ * resume.
54
+ */
55
+ export const RESUMABLE_LANE_STATES = ["done", "blocked"];
56
+ export function isResumableLaneState(state) {
57
+ return RESUMABLE_LANE_STATES.includes(state);
58
+ }
59
+ /**
60
+ * States `hq lanes resume` continues from. `interrupted` is the one a plain
61
+ * message refuses: the run ended without closing its state, and continuing
62
+ * it is a decision the operator makes by name. `done` and `blocked` are here
63
+ * so the one command covers every exited-with-a-session case.
64
+ */
65
+ export const RESUME_COMMAND_STATES = ["interrupted", "done", "blocked"];
66
+ export function isResumeCommandState(state) {
67
+ return RESUME_COMMAND_STATES.includes(state);
68
+ }
69
+ export const LANE_DELIVERY_MECHANISMS = ["dropbox", "resume", "none"];
70
+ /**
71
+ * `dropbox`: a process is (or is about to be) alive to drain the inbox.
72
+ * `resume`: the process has exited, the provider can resume, the state allows
73
+ * it, and a session id was recorded from the worker's own result.
74
+ * `none`: nothing can deliver; the caller must refuse and write nothing.
75
+ */
76
+ export function laneDeliveryMechanism(lane) {
77
+ if (lane.state === "queued" || lane.state === "running")
78
+ return "dropbox";
79
+ if (providerSupportsSessionResume(lane.provider) &&
80
+ isResumableLaneState(lane.state) &&
81
+ hasRecordedSessionId(lane)) {
82
+ return "resume";
83
+ }
84
+ return "none";
85
+ }
86
+ export function hasRecordedSessionId(lane) {
87
+ return (typeof lane.provider_session_id === "string" &&
88
+ lane.provider_session_id.trim().length > 0);
89
+ }
90
+ export function deliveryRefusalReason(lane) {
91
+ const state = lane.state;
92
+ switch (state) {
93
+ case "queued":
94
+ case "running":
95
+ return "";
96
+ case "awaiting_input":
97
+ return "Lane is awaiting_input. The worker has exited after writing an ask envelope. Use hq lanes questions answer <question-id>.";
98
+ case "done":
99
+ case "blocked": {
100
+ if (!providerSupportsSessionResume(lane.provider)) {
101
+ return `Lane is ${state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`;
102
+ }
103
+ if (!hasRecordedSessionId(lane)) {
104
+ return `Lane is ${state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`;
105
+ }
106
+ return "";
107
+ }
108
+ case "killed":
109
+ case "cancelled":
110
+ return `Lane is ${state}. The operator stopped it on purpose; a message does not restart it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
111
+ case "interrupted":
112
+ return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in: hq lanes resume <lane-id> [--text <message>].`;
113
+ case "failed":
114
+ return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
115
+ default: {
116
+ const _exhaustive = state;
117
+ return _exhaustive;
118
+ }
119
+ }
120
+ }
121
+ /** Reasons a resumable provider's exit recorded no session id. Never a fabricated value. */
122
+ export const SESSION_ID_ABSENT_REASONS = [
123
+ "worker_out_missing",
124
+ "no_result_json",
125
+ "no_session_id_field",
126
+ "no_thread_started_event",
127
+ ];
128
+ function nonNegativeInt(value) {
129
+ return typeof value === "number" && Number.isInteger(value) && value >= 0
130
+ ? value
131
+ : null;
132
+ }
133
+ /**
134
+ * Parse the `claude -p --output-format json` result. stdout is appended
135
+ * across runs of the same lane, so the last `type: "result"` object is this
136
+ * run's. Only `session_id` and the two cache counters are read out; the
137
+ * prompt, the response body and everything else in the payload stay on disk.
138
+ */
139
+ export function parseClaudeResultOutput(text) {
140
+ const lines = text.split("\n");
141
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
142
+ const line = (lines[i] ?? "").trim();
143
+ if (!line.startsWith("{"))
144
+ continue;
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(line);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
153
+ continue;
154
+ const rec = parsed;
155
+ if (rec.type !== "result")
156
+ continue;
157
+ const usage = rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
158
+ ? rec.usage
159
+ : {};
160
+ const sessionId = typeof rec.session_id === "string" && rec.session_id.trim().length > 0
161
+ ? rec.session_id.trim()
162
+ : null;
163
+ return {
164
+ session_id: sessionId,
165
+ session_id_absent: sessionId === null ? "no_session_id_field" : null,
166
+ cache_read_input_tokens: nonNegativeInt(usage.cache_read_input_tokens),
167
+ cache_creation_input_tokens: nonNegativeInt(usage.cache_creation_input_tokens),
168
+ };
169
+ }
170
+ return {
171
+ session_id: null,
172
+ session_id_absent: "no_result_json",
173
+ cache_read_input_tokens: null,
174
+ cache_creation_input_tokens: null,
175
+ };
176
+ }
177
+ /**
178
+ * Parse the `codex exec --json` event stream. The first event of a run is
179
+ * `{"type":"thread.started","thread_id":"<uuid>"}`; that id is what
180
+ * `codex exec resume <id>` takes, and a resumed run emits the same id again.
181
+ * Usage comes from the last `turn.completed` event: `cached_input_tokens`
182
+ * is the cache read and `cache_write_input_tokens` the cache creation.
183
+ * Nothing else on the stream is read out.
184
+ */
185
+ export function parseCodexEventOutput(text) {
186
+ let threadId = null;
187
+ let usage = {};
188
+ let sawEvent = false;
189
+ for (const raw of text.split("\n")) {
190
+ const line = raw.trim();
191
+ if (!line.startsWith("{"))
192
+ continue;
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(line);
196
+ }
197
+ catch {
198
+ continue;
199
+ }
200
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
201
+ continue;
202
+ const rec = parsed;
203
+ if (typeof rec.type !== "string")
204
+ continue;
205
+ sawEvent = true;
206
+ if (rec.type === "thread.started" && threadId === null) {
207
+ threadId =
208
+ typeof rec.thread_id === "string" && rec.thread_id.trim().length > 0
209
+ ? rec.thread_id.trim()
210
+ : null;
211
+ }
212
+ else if (rec.type === "turn.completed") {
213
+ usage =
214
+ rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
215
+ ? rec.usage
216
+ : {};
217
+ }
218
+ }
219
+ return {
220
+ session_id: threadId,
221
+ session_id_absent: threadId !== null ? null : sawEvent ? "no_thread_started_event" : "no_result_json",
222
+ cache_read_input_tokens: nonNegativeInt(usage.cached_input_tokens),
223
+ cache_creation_input_tokens: nonNegativeInt(usage.cache_write_input_tokens),
224
+ };
225
+ }
226
+ /** Per-provider stdout parser; null for a provider whose exit carries nothing to record. */
227
+ export function parseWorkerRunOutput(provider, text) {
228
+ switch (parseLaneProvider(provider)) {
229
+ case "claude":
230
+ return parseClaudeResultOutput(text);
231
+ case "codex":
232
+ return parseCodexEventOutput(text);
233
+ case "grok":
234
+ return null;
235
+ default:
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * Read this run's result from the worker's own stdout. stdout is opened in
241
+ * append mode across runs, so only bytes past `workerOutOffset` (recorded in
242
+ * spawn.json at launch) are this run's. Returns null for a provider whose
243
+ * exit carries nothing to record, so those lanes keep behaving exactly as
244
+ * before.
245
+ */
246
+ export function readWorkerRunResult(runDir, provider,
247
+ /** Bytes of worker.out that belong to earlier runs; parsing starts after them. */
248
+ workerOutOffset = 0) {
249
+ if (!providerSupportsSessionResume(provider))
250
+ return null;
251
+ const outPath = path.join(runDir, "logs", "worker.out");
252
+ let text;
253
+ try {
254
+ const bytes = fs.readFileSync(outPath);
255
+ text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
256
+ }
257
+ catch (err) {
258
+ const code = err.code;
259
+ if (code === "ENOENT") {
260
+ return {
261
+ session_id: null,
262
+ session_id_absent: "worker_out_missing",
263
+ cache_read_input_tokens: null,
264
+ cache_creation_input_tokens: null,
265
+ };
266
+ }
267
+ process.stderr.write(`hq lanes: failed to read ${outPath}: ${err instanceof Error ? err.message : String(err)}\n`);
268
+ throw err;
269
+ }
270
+ return parseWorkerRunOutput(provider, text);
271
+ }
272
+ export const LAUNCH_TRIGGERS = ["admit", "answered_questions", "message", "resume"];
273
+ /**
274
+ * Refusal for `hq lanes resume`, in precedence order: wrong state, provider
275
+ * without a resume path, no recorded session. Null when the resume can go.
276
+ */
277
+ export function resumeCommandRefusal(lane) {
278
+ if (!isResumeCommandState(lane.state)) {
279
+ return {
280
+ error: "lane_not_resumable",
281
+ message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
282
+ ? "use hq lanes questions answer <question-id>."
283
+ : lane.state === "queued" || lane.state === "running"
284
+ ? "use hq lanes message for a live lane."
285
+ : "a lane in this state is not resumed."}`,
286
+ };
287
+ }
288
+ if (!providerSupportsSessionResume(lane.provider)) {
289
+ return {
290
+ error: "resume_unsupported",
291
+ message: `Lane is ${lane.state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`,
292
+ };
293
+ }
294
+ if (!hasRecordedSessionId(lane)) {
295
+ return {
296
+ error: "no_provider_session",
297
+ message: `Lane is ${lane.state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`,
298
+ };
299
+ }
300
+ return null;
301
+ }
302
+ /**
303
+ * Decide fresh-vs-resume for a spawn. Admission always starts fresh with the
304
+ * brief. Answered questions resume the recorded session when the provider
305
+ * can, else start fresh with the answers file (today's behaviour). An operator
306
+ * message can only be a resume, and only when `laneDeliveryMechanism` says so.
307
+ * `hq lanes resume` is also always a resume, gated by `resumeCommandRefusal`.
308
+ */
309
+ export function planLaneLaunch(lane, trigger) {
310
+ switch (trigger.kind) {
311
+ case "admit":
312
+ return { mode: "fresh" };
313
+ case "answered_questions":
314
+ if (providerSupportsSessionResume(lane.provider) && hasRecordedSessionId(lane)) {
315
+ return { mode: "resume", session_id: lane.provider_session_id };
316
+ }
317
+ return { mode: "fresh" };
318
+ case "message": {
319
+ if (laneDeliveryMechanism(lane) !== "resume" || !hasRecordedSessionId(lane)) {
320
+ throw new LanesError("lane_not_accepting_messages", deliveryRefusalReason(lane) || `Lane is ${lane.state} and cannot be resumed`, { state: lane.state, provider: lane.provider });
321
+ }
322
+ return { mode: "resume", session_id: lane.provider_session_id };
323
+ }
324
+ case "resume": {
325
+ const refusal = resumeCommandRefusal(lane);
326
+ if (refusal !== null || !hasRecordedSessionId(lane)) {
327
+ throw new LanesError(refusal?.error ?? "no_provider_session", refusal?.message ?? `Lane is ${lane.state} and has no recorded provider session`, { state: lane.state, provider: lane.provider });
328
+ }
329
+ return { mode: "resume", session_id: lane.provider_session_id };
330
+ }
331
+ default: {
332
+ const _exhaustive = trigger;
333
+ return _exhaustive;
334
+ }
335
+ }
336
+ }
337
+ /** Lane states a launch may claim `running` from, per trigger. */
338
+ export function claimableStatesFor(trigger) {
339
+ switch (trigger.kind) {
340
+ case "admit":
341
+ return ["queued"];
342
+ case "answered_questions":
343
+ return ["awaiting_input"];
344
+ case "message":
345
+ return RESUMABLE_LANE_STATES;
346
+ case "resume":
347
+ return RESUME_COMMAND_STATES;
348
+ default: {
349
+ const _exhaustive = trigger;
350
+ return _exhaustive;
351
+ }
352
+ }
353
+ }
354
+ //# sourceMappingURL=delivery.js.map