@indigoai-us/hq-cli 5.134.0 → 5.135.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.
@@ -0,0 +1,409 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { LanesError } from "./errors.js";
6
+ export const MONITOR_MAX_TIMEOUT_MS = 1_800_000;
7
+ export const MONITOR_UNCOVERED_EXIT_CODE = 2;
8
+ export const MONITOR_ERROR_EXIT_CODE = 3;
9
+ export const MONITOR_TRANSCRIPT_MAX_BYTES = 512 * 1024;
10
+ export const MONITOR_TASK_OUTPUT_MAX_BYTES = 64 * 1024;
11
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
12
+ const LANE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
13
+ const TERMINAL_TASK_OUTPUT = /(?:\[(?:exited|killed|terminated|stopped)(?:\b|\])|task\s+(?:stopped|killed)|monitor\s+(?:stopped|expired))/i;
14
+ export const MONITOR_ENGINES = [
15
+ "claude",
16
+ "codex",
17
+ "grok",
18
+ ];
19
+ function stringValue(value) {
20
+ return typeof value === "string" && value.length > 0 ? value : undefined;
21
+ }
22
+ function numberValue(value) {
23
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
24
+ }
25
+ function contentText(content) {
26
+ if (typeof content === "string")
27
+ return content;
28
+ if (!Array.isArray(content))
29
+ return "";
30
+ return content
31
+ .map((item) => {
32
+ if (typeof item === "string")
33
+ return item;
34
+ if (item && typeof item === "object") {
35
+ const text = item.text;
36
+ const contentValue = item.content;
37
+ return typeof text === "string"
38
+ ? text
39
+ : typeof contentValue === "string"
40
+ ? contentValue
41
+ : "";
42
+ }
43
+ return "";
44
+ })
45
+ .join("\n");
46
+ }
47
+ function toolUseBlocks(row) {
48
+ const message = row.message;
49
+ if (!message || typeof message !== "object")
50
+ return [];
51
+ const content = message.content;
52
+ if (!Array.isArray(content))
53
+ return [];
54
+ return content.filter((item) => Boolean(item && typeof item === "object" && item.type === "tool_use"));
55
+ }
56
+ function toolResultText(row) {
57
+ const message = row.message;
58
+ if (!message || typeof message !== "object")
59
+ return "";
60
+ return contentText(message.content);
61
+ }
62
+ function toolResultUseId(row) {
63
+ const message = row.message;
64
+ if (!message || typeof message !== "object")
65
+ return undefined;
66
+ const content = message.content;
67
+ if (!Array.isArray(content))
68
+ return undefined;
69
+ for (const item of content) {
70
+ if (!item || typeof item !== "object")
71
+ continue;
72
+ const block = item;
73
+ if (block.type === "tool_result") {
74
+ return stringValue(block.tool_use_id) ?? stringValue(block.toolUseId);
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+ function monitorTaskId(text) {
80
+ const match = /\btask\s+([A-Za-z0-9_-]+)\b/i.exec(text);
81
+ return match?.[1];
82
+ }
83
+ function isMissingFilesystemEntry(err) {
84
+ if (!err || typeof err !== "object")
85
+ return false;
86
+ const code = err.code;
87
+ return code === "ENOENT" || code === "ENOTDIR";
88
+ }
89
+ function monitorEvidenceUnreadable(filePath, err) {
90
+ const cause = err instanceof Error ? err.message : String(err);
91
+ return new LanesError("monitor_evidence_unreadable", `Unable to read monitor evidence ${filePath}: ${cause}`, { path: filePath, cause });
92
+ }
93
+ /**
94
+ * Parse only the JSONL shape emitted by Claude Code's Monitor tool. The
95
+ * caller supplies a bounded tail; malformed or partial lines are ignored so a
96
+ * torn final write cannot turn into positive liveness evidence.
97
+ */
98
+ export function parseMonitorTranscript(text) {
99
+ const pending = [];
100
+ const stoppedIds = new Set();
101
+ for (const line of text.split("\n")) {
102
+ if (line.length === 0)
103
+ continue;
104
+ let row;
105
+ try {
106
+ const parsed = JSON.parse(line);
107
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
108
+ continue;
109
+ row = parsed;
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ for (const block of toolUseBlocks(row)) {
115
+ const name = stringValue(block.name);
116
+ const input = block.input;
117
+ const inputObject = input && typeof input === "object" && !Array.isArray(input)
118
+ ? input
119
+ : {};
120
+ if (name === "Monitor") {
121
+ const command = stringValue(inputObject.command);
122
+ const startedAt = stringValue(row.timestamp);
123
+ const timeoutMs = numberValue(inputObject.timeout_ms);
124
+ if (command && startedAt && timeoutMs && timeoutMs > 0) {
125
+ pending.push({
126
+ command,
127
+ startedAt,
128
+ timeoutMs,
129
+ toolUseId: stringValue(block.id),
130
+ persistent: typeof inputObject.persistent === "boolean"
131
+ ? inputObject.persistent
132
+ : undefined,
133
+ });
134
+ }
135
+ }
136
+ else if (name === "TaskStop") {
137
+ const taskId = stringValue(inputObject.task_id) ?? stringValue(inputObject.taskId);
138
+ if (taskId)
139
+ stoppedIds.add(taskId);
140
+ }
141
+ }
142
+ const toolUseResult = row.toolUseResult;
143
+ if (toolUseResult &&
144
+ typeof toolUseResult === "object" &&
145
+ !Array.isArray(toolUseResult)) {
146
+ const result = toolUseResult;
147
+ const taskId = stringValue(result.taskId) ??
148
+ stringValue(result.task_id) ??
149
+ monitorTaskId(toolResultText(row));
150
+ const resultUseId = toolResultUseId(row);
151
+ const candidate = resultUseId
152
+ ? pending.find((item) => item.taskId === undefined && item.toolUseId === resultUseId)
153
+ : pending.find((item) => item.taskId === undefined);
154
+ if (taskId && candidate) {
155
+ candidate.taskId = taskId;
156
+ candidate.timeoutMs =
157
+ numberValue(result.timeoutMs) ?? candidate.timeoutMs;
158
+ if (typeof result.persistent === "boolean") {
159
+ candidate.persistent = result.persistent;
160
+ }
161
+ const file = result.file;
162
+ if (file && typeof file === "object" && !Array.isArray(file)) {
163
+ candidate.outputPath = stringValue(file.filePath);
164
+ }
165
+ }
166
+ }
167
+ else {
168
+ const taskId = monitorTaskId(toolResultText(row));
169
+ const resultUseId = toolResultUseId(row);
170
+ const candidate = resultUseId
171
+ ? pending.find((item) => item.taskId === undefined && item.toolUseId === resultUseId)
172
+ : pending.find((item) => item.taskId === undefined);
173
+ if (taskId && candidate)
174
+ candidate.taskId = taskId;
175
+ }
176
+ }
177
+ return pending
178
+ .filter((item) => Boolean(item.taskId))
179
+ .map((item) => ({
180
+ taskId: item.taskId,
181
+ command: item.command,
182
+ startedAt: item.startedAt,
183
+ timeoutMs: item.timeoutMs,
184
+ persistent: item.persistent,
185
+ stopped: item.stopped || stoppedIds.has(item.taskId),
186
+ outputPresent: false,
187
+ outputPath: item.outputPath,
188
+ }));
189
+ }
190
+ /** Return argv only for the exact command form the gate arms. */
191
+ export function parseCanonicalWatchCommand(command) {
192
+ const tokens = command.trim().split(/\s+/u);
193
+ if (tokens.length < 4)
194
+ return null;
195
+ const executable = path.basename(tokens[0]);
196
+ if (executable !== "hq" || tokens[1] !== "lanes" || tokens[2] !== "watch") {
197
+ return null;
198
+ }
199
+ const laneIds = tokens.slice(3);
200
+ if (laneIds.some((laneId) => !LANE_ID_PATTERN.test(laneId)))
201
+ return null;
202
+ return laneIds;
203
+ }
204
+ function taskOutputIsTerminal(outputText) {
205
+ return outputText ? TERMINAL_TASK_OUTPUT.test(outputText) : false;
206
+ }
207
+ export function isMonitorTaskLive(task, now) {
208
+ if (task.stopped || task.processAlive !== true)
209
+ return false;
210
+ if (taskOutputIsTerminal(task.outputText))
211
+ return false;
212
+ const startedAt = Date.parse(task.startedAt);
213
+ if (!Number.isFinite(startedAt) || !Number.isFinite(task.timeoutMs) || task.timeoutMs <= 0) {
214
+ return false;
215
+ }
216
+ const deadline = startedAt + Math.min(task.timeoutMs, MONITOR_MAX_TIMEOUT_MS);
217
+ return now.getTime() < deadline;
218
+ }
219
+ export function coveredLaneIds(evidence, now) {
220
+ const covered = new Set();
221
+ for (const task of evidence.tasks) {
222
+ if (!isMonitorTaskLive(task, now))
223
+ continue;
224
+ const laneIds = parseCanonicalWatchCommand(task.command);
225
+ if (!laneIds)
226
+ continue;
227
+ for (const laneId of laneIds)
228
+ covered.add(laneId);
229
+ }
230
+ return covered;
231
+ }
232
+ function boundedTail(filePath, maxBytes) {
233
+ let fd;
234
+ let content = null;
235
+ let failure;
236
+ try {
237
+ const stat = fs.statSync(filePath);
238
+ const size = Math.min(stat.size, maxBytes);
239
+ fd = fs.openSync(filePath, "r");
240
+ const buffer = Buffer.alloc(size);
241
+ fs.readSync(fd, buffer, 0, size, Math.max(0, stat.size - size));
242
+ content = buffer.toString("utf8");
243
+ }
244
+ catch (err) {
245
+ if (!isMissingFilesystemEntry(err))
246
+ failure = monitorEvidenceUnreadable(filePath, err);
247
+ }
248
+ if (fd !== undefined) {
249
+ try {
250
+ fs.closeSync(fd);
251
+ }
252
+ catch (err) {
253
+ if (!isMissingFilesystemEntry(err) && !failure) {
254
+ failure = monitorEvidenceUnreadable(filePath, err);
255
+ }
256
+ }
257
+ }
258
+ if (failure)
259
+ throw failure;
260
+ return content;
261
+ }
262
+ function configDirs(home, env) {
263
+ const found = new Set();
264
+ const configured = env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, ".claude");
265
+ found.add(configured);
266
+ try {
267
+ for (const name of fs.readdirSync(home)) {
268
+ if (name === ".claude" || name.startsWith(".claude-")) {
269
+ found.add(path.join(home, name));
270
+ found.add(path.join(home, name, ".claude"));
271
+ }
272
+ }
273
+ }
274
+ catch (err) {
275
+ if (!isMissingFilesystemEntry(err))
276
+ throw monitorEvidenceUnreadable(home, err);
277
+ }
278
+ const accountRoot = path.join(home, ".claude-accounts");
279
+ try {
280
+ for (const account of fs.readdirSync(accountRoot)) {
281
+ found.add(path.join(accountRoot, account, ".claude"));
282
+ }
283
+ }
284
+ catch (err) {
285
+ if (!isMissingFilesystemEntry(err))
286
+ throw monitorEvidenceUnreadable(accountRoot, err);
287
+ }
288
+ return [...found];
289
+ }
290
+ function transcriptPathsFor(home, env, sessionId) {
291
+ const paths = [];
292
+ for (const configDir of configDirs(home, env)) {
293
+ const projectsDir = path.join(configDir, "projects");
294
+ let projectDirs;
295
+ try {
296
+ projectDirs = fs.readdirSync(projectsDir);
297
+ }
298
+ catch (err) {
299
+ if (!isMissingFilesystemEntry(err))
300
+ throw monitorEvidenceUnreadable(projectsDir, err);
301
+ continue;
302
+ }
303
+ for (const projectDir of projectDirs) {
304
+ const candidate = path.join(projectsDir, projectDir, `${sessionId}.jsonl`);
305
+ try {
306
+ if (fs.statSync(candidate).isFile())
307
+ paths.push(candidate);
308
+ }
309
+ catch (err) {
310
+ if (!isMissingFilesystemEntry(err))
311
+ throw monitorEvidenceUnreadable(candidate, err);
312
+ }
313
+ }
314
+ }
315
+ return [...new Set(paths)];
316
+ }
317
+ function taskOutputCandidates(transcriptPath, sessionId, task) {
318
+ const candidates = [];
319
+ if (task.outputPath)
320
+ candidates.push(task.outputPath);
321
+ const projectDir = path.basename(path.dirname(transcriptPath));
322
+ try {
323
+ for (const tempName of fs.readdirSync(os.tmpdir())) {
324
+ if (!tempName.startsWith("claude-"))
325
+ continue;
326
+ candidates.push(path.join(os.tmpdir(), tempName, projectDir, sessionId, "tasks", `${task.taskId}.output`));
327
+ }
328
+ }
329
+ catch (err) {
330
+ if (!isMissingFilesystemEntry(err))
331
+ throw monitorEvidenceUnreadable(os.tmpdir(), err);
332
+ }
333
+ return [...new Set(candidates)];
334
+ }
335
+ function defaultProcessCommands() {
336
+ try {
337
+ const output = execFileSync("ps", ["-eww", "-o", "args="], {
338
+ encoding: "utf8",
339
+ stdio: ["ignore", "pipe", "ignore"],
340
+ });
341
+ return output
342
+ .split("\n")
343
+ .map((line) => line.trim())
344
+ .filter((line) => line.length > 0);
345
+ }
346
+ catch {
347
+ return [];
348
+ }
349
+ }
350
+ function processHasExactCommand(processCommands, command) {
351
+ return processCommands.some((line) => line.trim() === command);
352
+ }
353
+ export function readLiveMonitorEvidence(sessionId, options = {}) {
354
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
355
+ throw new LanesError("monitor_invalid_session", `Invalid senior session id ${JSON.stringify(sessionId)}`);
356
+ }
357
+ const home = options.homedir?.() ?? os.homedir();
358
+ const env = options.env ?? process.env;
359
+ const transcriptMaxBytes = options.transcriptMaxBytes ?? MONITOR_TRANSCRIPT_MAX_BYTES;
360
+ const taskOutputMaxBytes = options.taskOutputMaxBytes ?? MONITOR_TASK_OUTPUT_MAX_BYTES;
361
+ const processCommands = options.readProcessCommands?.() ?? defaultProcessCommands();
362
+ const tasks = new Map();
363
+ const transcriptPaths = transcriptPathsFor(home, env, sessionId);
364
+ const taskOutputPaths = [];
365
+ for (const transcriptPath of transcriptPaths) {
366
+ const transcript = boundedTail(transcriptPath, transcriptMaxBytes);
367
+ if (transcript === null)
368
+ continue;
369
+ for (const parsed of parseMonitorTranscript(transcript)) {
370
+ const outputCandidates = taskOutputCandidates(transcriptPath, sessionId, parsed);
371
+ let outputPath;
372
+ let outputText;
373
+ for (const candidate of outputCandidates) {
374
+ const content = boundedTail(candidate, taskOutputMaxBytes);
375
+ if (content === null)
376
+ continue;
377
+ outputPath = candidate;
378
+ outputText = content;
379
+ taskOutputPaths.push(candidate);
380
+ break;
381
+ }
382
+ tasks.set(parsed.taskId, {
383
+ ...parsed,
384
+ outputPresent: outputPath !== undefined,
385
+ outputPath: outputPath ?? parsed.outputPath,
386
+ outputText,
387
+ processAlive: processHasExactCommand(processCommands, parsed.command)
388
+ ? true
389
+ : undefined,
390
+ });
391
+ }
392
+ }
393
+ return {
394
+ sessionId,
395
+ tasks: [...tasks.values()],
396
+ transcriptPaths,
397
+ taskOutputPaths: [...new Set(taskOutputPaths)],
398
+ processCommands,
399
+ };
400
+ }
401
+ export function monitorReminderText() {
402
+ return [
403
+ "For active Claude senior lanes, arm one live Claude Code Monitor task per uncovered lane:",
404
+ 'Monitor(command="hq lanes watch <lane-id>...", timeout_ms=1800000, persistent=true)',
405
+ "Use `hq lanes monitor-check --session <id>` to verify coverage. `persistent=true` is requested where the installed Monitor tool honors it; Claude Code caps Monitor tasks at 30 minutes, so re-arm after expiry.",
406
+ "The caller must pass `--engine codex` or `--engine grok` (or set HQ_CONDUCT_ENGINE) for sessions whose engine has no Claude Monitor tool; those engines are never required to provide one.",
407
+ ].join("\n");
408
+ }
409
+ //# sourceMappingURL=monitor.js.map
@@ -21,6 +21,9 @@ declare const PROVIDER_COMMAND: Record<LaneProvider, string>;
21
21
  export declare function providerCommandOnPath(command: string, pathEnv: string | undefined): boolean;
22
22
  /** Per-provider model flag. A missing entry means --model is refused. */
23
23
  export declare const PROVIDER_MODEL_FLAG: Record<LaneProvider, string>;
24
+ export declare const CLAUDE_AUTO_COMPACT_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
25
+ /** Codex CLI overrides required for its ChatGPT fast service tier. */
26
+ export declare const CODEX_FAST_MODE_FLAGS: readonly ["-c", "service_tier=\"fast\"", "--enable", "fast_mode"];
24
27
  export declare function parseCliModel(raw: string | undefined): string | undefined;
25
28
  export declare function resolveLaneModel(input: {
26
29
  cliModel?: string;
@@ -28,6 +31,8 @@ export declare function resolveLaneModel(input: {
28
31
  providerModel?: string;
29
32
  }): string | undefined;
30
33
  export declare function appendModelFlag(provider: LaneProvider, args: string[], model: string | undefined): string[];
34
+ /** Add Codex fast-mode flags before the prompt/positional separator. */
35
+ export declare function appendFastModeFlags(provider: LaneProvider, args: string[], enabled: boolean | undefined): string[];
31
36
  export type DetachedStdio = ["ignore", number, number];
32
37
  export interface DetachedSpawnOptions {
33
38
  detached: true;
@@ -190,6 +195,8 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
190
195
  model?: string;
191
196
  /** Validated against the provider's own value set by appendEffortFlag. */
192
197
  effort?: string;
198
+ /** Enable Codex's fast service tier for this resolved model. */
199
+ fastMode?: boolean;
193
200
  /** Continue a recorded session. Only providers in PROVIDER_SESSION_RESUME accept this. */
194
201
  resume?: ProviderResumeInput;
195
202
  /**
@@ -202,7 +209,7 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
202
209
  export declare function workerEnv(base: NodeJS.ProcessEnv, resolved: {
203
210
  envVar: string;
204
211
  home: string;
205
- }, laneId: string, hqRoot: string): NodeJS.ProcessEnv;
212
+ }, laneId: string, hqRoot: string, autoCompactTokens?: number): NodeJS.ProcessEnv;
206
213
  export declare function markSpawnFailure(hqRoot: string, laneId: string, reasonRaw: string, details: Record<string, unknown>): LaneRecord;
207
214
  /**
208
215
  * Spec §5.2 executing-side check. Compares this machine's identity to
@@ -219,6 +226,7 @@ export interface SpawnManifestFile {
219
226
  } | null;
220
227
  /** Byte length of logs/worker.out before this run started; its result is after this. */
221
228
  worker_out_offset?: number;
229
+ fast_mode?: boolean;
222
230
  }
223
231
  /** Read the per-run manifest. Missing file or field reads as absent. */
224
232
  export declare function readSpawnManifest(runDir: string): SpawnManifestFile;
@@ -268,6 +276,7 @@ export declare function envelopeRepairMode(provider: LaneProvider): EnvelopeRepa
268
276
  * prompt as a fresh single turn on the same account (`fresh_turn` mode). It
269
277
  * replaces `worker.pid` and rewrites `spawn.json` so the watcher's next tick
270
278
  * follows the new process. The account is the one already bound on the record.
279
+ * Fast-mode evidence is updated after the replacement is proven detached.
271
280
  */
272
281
  export declare function spawnEnvelopeRepair(hqRoot: string, lane: LaneRecord, input: EnvelopeRepairSpawnInput, deps?: SpawnLaneDeps): number;
273
282
  export { PROVIDER_HOME_ENV, PROVIDER_COMMAND };
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { PROVIDER_HOME_ENV, accountStatus, isLoggedOut, resolveAccount, } from "./accounts.js";
15
15
  import { COMPACTION_PROMPT, compactionAfterUsage, compactionDue, compactLaneSession, } from "./compaction.js";
16
16
  import { installDrainHook } from "./hooks-install.js";
17
- import { loadLanesConfig, providerCompactAfterMs } from "./config.js";
17
+ import { loadLanesConfig, providerAutoCompactTokens, providerCompactAfterMs, } from "./config.js";
18
18
  import { appendEffortFlag, resolveLaneEffort } from "./effort.js";
19
19
  import { claimableStatesFor, planLaneLaunch, providerSupportsSessionResume, } from "./delivery.js";
20
20
  import { AccountResolutionError, LanesError } from "./errors.js";
@@ -64,6 +64,14 @@ export const PROVIDER_MODEL_FLAG = {
64
64
  codex: "-m",
65
65
  grok: "-m",
66
66
  };
67
+ export const CLAUDE_AUTO_COMPACT_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
68
+ /** Codex CLI overrides required for its ChatGPT fast service tier. */
69
+ export const CODEX_FAST_MODE_FLAGS = [
70
+ "-c",
71
+ 'service_tier="fast"',
72
+ "--enable",
73
+ "fast_mode",
74
+ ];
67
75
  export function parseCliModel(raw) {
68
76
  if (raw === undefined)
69
77
  return undefined;
@@ -101,6 +109,16 @@ export function appendModelFlag(provider, args, model) {
101
109
  }
102
110
  return [...args, flag, model];
103
111
  }
112
+ /** Add Codex fast-mode flags before the prompt/positional separator. */
113
+ export function appendFastModeFlags(provider, args, enabled) {
114
+ if (provider !== "codex" || enabled !== true)
115
+ return args;
116
+ const flags = [...CODEX_FAST_MODE_FLAGS];
117
+ const end = args.indexOf("--");
118
+ if (end === -1)
119
+ return [...args, ...flags];
120
+ return [...args.slice(0, end), ...flags, ...args.slice(end)];
121
+ }
104
122
  export const PID_LIVENESS_DECISIONS = [
105
123
  "esrch",
106
124
  "starttime_mismatch",
@@ -695,10 +713,10 @@ export function buildProviderCommand(provider, input) {
695
713
  }
696
714
  return {
697
715
  command,
698
- args: appendEffortFlag(parsed, appendModelFlag(parsed, args, input.model), input.effort),
716
+ args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, args, input.model), input.effort), input.fastMode),
699
717
  };
700
718
  }
701
- export function workerEnv(base, resolved, laneId, hqRoot) {
719
+ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens) {
702
720
  const accepted = Object.values(PROVIDER_HOME_ENV);
703
721
  if (!accepted.includes(resolved.envVar)) {
704
722
  throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
@@ -707,6 +725,10 @@ export function workerEnv(base, resolved, laneId, hqRoot) {
707
725
  delete env.HQ_PROJECT_ID;
708
726
  delete env.HQ_STORY_ID;
709
727
  env[resolved.envVar] = resolved.home;
728
+ delete env[CLAUDE_AUTO_COMPACT_ENV];
729
+ if (resolved.envVar === PROVIDER_HOME_ENV.claude && autoCompactTokens !== undefined) {
730
+ env[CLAUDE_AUTO_COMPACT_ENV] = String(autoCompactTokens);
731
+ }
710
732
  env.HQ_LANE_ID = laneId;
711
733
  env.HQ_ROOT = hqRoot;
712
734
  return env;
@@ -883,6 +905,7 @@ function writeSpawnManifest(runDir, input) {
883
905
  envelope_path: input.envelopePath,
884
906
  resume: input.resume,
885
907
  worker_out_offset: input.workerOutOffset,
908
+ ...(input.fastMode ? { fast_mode: true } : {}),
886
909
  }, null, 2)}\n`);
887
910
  }
888
911
  /** Arm (or re-arm) the detached per-lane watcher. */
@@ -944,6 +967,8 @@ export function readSpawnManifest(runDir) {
944
967
  rec.worker_out_offset >= 0) {
945
968
  out.worker_out_offset = rec.worker_out_offset;
946
969
  }
970
+ if (typeof rec.fast_mode === "boolean")
971
+ out.fast_mode = rec.fast_mode;
947
972
  if (rec.resume &&
948
973
  typeof rec.resume === "object" &&
949
974
  typeof rec.resume.session_id === "string") {
@@ -1042,7 +1067,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1042
1067
  process.stderr.write(`hq lanes: drain hook install failed for ${resolved.home}: ${err instanceof Error ? err.message : String(err)}\n`);
1043
1068
  throw err;
1044
1069
  }
1045
- const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot);
1046
1070
  const briefPath = path.join(runDir, "brief.md");
1047
1071
  const envelopePath = nextEnvelopePath(runDir);
1048
1072
  const lastMessagePath = path.join(logsDir, "last-message.txt");
@@ -1051,6 +1075,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1051
1075
  ? `Previously answered questions are at ${answersPath}; treat those choices as operator decisions that outrank the brief.`
1052
1076
  : undefined;
1053
1077
  const accountCfg = config.providers[lane.provider].accounts[resolved.id];
1078
+ const autoCompactTokens = lane.provider === "claude"
1079
+ ? providerAutoCompactTokens(config.providers.claude)
1080
+ : undefined;
1081
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens);
1054
1082
  const model = resolveLaneModel({
1055
1083
  cliModel: lane.model,
1056
1084
  accountModel: accountCfg?.model,
@@ -1062,6 +1090,9 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1062
1090
  providerEffort: config.providers[lane.provider].effort,
1063
1091
  model,
1064
1092
  });
1093
+ const fastMode = lane.provider === "codex" &&
1094
+ model !== undefined &&
1095
+ (config.providers.codex.fast_mode_models ?? []).includes(model);
1065
1096
  const resume = plan.mode === "resume"
1066
1097
  ? {
1067
1098
  session_id: plan.session_id,
@@ -1090,6 +1121,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1090
1121
  extraPrompt,
1091
1122
  model,
1092
1123
  effort: effort.effort,
1124
+ fastMode,
1093
1125
  resume,
1094
1126
  });
1095
1127
  // A systemd PATH mismatch is an environment defect, not a lane failure.
@@ -1137,6 +1169,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1137
1169
  lastMessagePath,
1138
1170
  model,
1139
1171
  effort: effort.effort,
1172
+ fastMode,
1140
1173
  resume: { session_id: resume.session_id, prompt: COMPACTION_PROMPT },
1141
1174
  });
1142
1175
  compaction = compactLaneSession({
@@ -1187,6 +1220,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1187
1220
  next.effort = effort.effort;
1188
1221
  else
1189
1222
  delete next.effort;
1223
+ if (fastMode)
1224
+ next.fast_mode = true;
1225
+ else
1226
+ delete next.fast_mode;
1190
1227
  // Freeze the counters of the run that followed the previous compaction
1191
1228
  // before this launch replaces last_resume.
1192
1229
  if (current.last_compaction && current.last_compaction.after === null) {
@@ -1257,6 +1294,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1257
1294
  envelopePath,
1258
1295
  resume: resume ? { session_id: resume.session_id } : null,
1259
1296
  workerOutOffset,
1297
+ fastMode,
1260
1298
  });
1261
1299
  }
1262
1300
  catch (err) {
@@ -1300,6 +1338,7 @@ export function envelopeRepairMode(provider) {
1300
1338
  * prompt as a fresh single turn on the same account (`fresh_turn` mode). It
1301
1339
  * replaces `worker.pid` and rewrites `spawn.json` so the watcher's next tick
1302
1340
  * follows the new process. The account is the one already bound on the record.
1341
+ * Fast-mode evidence is updated after the replacement is proven detached.
1303
1342
  */
1304
1343
  export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1305
1344
  const env = deps.env ?? process.env;
@@ -1321,7 +1360,10 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1321
1360
  existsSync,
1322
1361
  model: lane.model,
1323
1362
  });
1324
- const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot);
1363
+ const autoCompactTokens = lane.provider === "claude"
1364
+ ? providerAutoCompactTokens(config.providers.claude)
1365
+ : undefined;
1366
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens);
1325
1367
  const accountCfg = config.providers[lane.provider].accounts[resolved.id];
1326
1368
  const model = resolveLaneModel({
1327
1369
  cliModel: lane.model,
@@ -1334,6 +1376,9 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1334
1376
  providerEffort: config.providers[lane.provider].effort,
1335
1377
  model,
1336
1378
  });
1379
+ const fastMode = lane.provider === "codex" &&
1380
+ model !== undefined &&
1381
+ (config.providers.codex.fast_mode_models ?? []).includes(model);
1337
1382
  const repairPrompt = envelopeRepairPrompt({
1338
1383
  envelopePath: input.envelopePath,
1339
1384
  error: input.error,
@@ -1346,6 +1391,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1346
1391
  lastMessagePath: path.join(logsDir, "last-message.txt"),
1347
1392
  model,
1348
1393
  effort: effort.effort,
1394
+ fastMode,
1349
1395
  ...(mode === "resume" && sessionId !== null
1350
1396
  ? { resume: { session_id: sessionId, prompt: repairPrompt } }
1351
1397
  : { freshTurnPrompt: repairPrompt }),
@@ -1371,6 +1417,15 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1371
1417
  envelopePath: previous.envelope_path ?? input.envelopePath,
1372
1418
  resume: mode === "resume" && sessionId !== null ? { session_id: sessionId } : null,
1373
1419
  workerOutOffset,
1420
+ fastMode,
1421
+ });
1422
+ retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
1423
+ const next = { ...current };
1424
+ if (fastMode)
1425
+ next.fast_mode = true;
1426
+ else
1427
+ delete next.fast_mode;
1428
+ return next;
1374
1429
  });
1375
1430
  }
1376
1431
  catch (err) {
@@ -300,6 +300,8 @@ export interface LaneRecord {
300
300
  effort?: string;
301
301
  /** Where `effort` came from. `cli` pins it across later spawns. */
302
302
  effort_source?: "cli" | "account" | "provider" | "default_rule" | "none";
303
+ /** Whether the last spawn used Codex fast mode, including false when disabled. */
304
+ fast_mode?: boolean;
303
305
  /** Time of the last operator message accepted for this lane. Drives compaction. */
304
306
  last_message_at?: string;
305
307
  /** Last automatic compaction of a resumed session (compaction.ts). */
@@ -540,11 +542,15 @@ export interface ProviderConfig {
540
542
  model?: string;
541
543
  /** Default effort for this provider when the account does not set one. */
542
544
  effort?: string;
545
+ /** Codex model ids that should use the provider's fast service tier. */
546
+ fast_mode_models?: string[];
543
547
  /**
544
548
  * Resume-time compaction threshold: a resumed session whose last message is
545
549
  * at least this old is compacted first. Absent means the one-hour default.
546
550
  */
547
551
  compact_after_ms?: number;
552
+ /** Claude Code auto-compaction threshold, in tokens. */
553
+ auto_compact_tokens?: number;
548
554
  /**
549
555
  * Watcher throttle for `lane_progress` thread events: at most one per this
550
556
  * interval, and only on a tick that saw new tool activity or output. Absent