@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.
- package/CHANGELOG.md +22 -0
- package/dist/command-catalog.generated.d.ts +40 -0
- package/dist/command-catalog.generated.js +52 -0
- package/dist/commands/lanes.d.ts +4 -2
- package/dist/commands/lanes.js +150 -2
- package/dist/lib/lanes/config.d.ts +7 -0
- package/dist/lib/lanes/config.js +28 -1
- package/dist/lib/lanes/index.d.ts +5 -1
- package/dist/lib/lanes/index.js +3 -1
- package/dist/lib/lanes/monitor.d.ts +48 -0
- package/dist/lib/lanes/monitor.js +409 -0
- package/dist/lib/lanes/spawn.d.ts +10 -1
- package/dist/lib/lanes/spawn.js +60 -5
- package/dist/lib/lanes/types.d.ts +6 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watch.d.ts +22 -0
- package/dist/lib/lanes/watch.js +122 -0
- package/package.json +1 -1
|
@@ -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 };
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -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
|
|
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
|