@indigoai-us/hq-cli 5.208.0 → 5.210.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.210.0] — 2026-09-26
6
+
7
+ ### Fixed
8
+
9
+ - Lane messages delivered during a provider turn are replayed after transcript
10
+ compaction when `lanes.message-compaction-replay` is enabled, limited to that
11
+ provider turn. Monitor events keep their data framing.
12
+
13
+ ## [5.209.0] — 2026-09-26
14
+
15
+ ### Added
16
+
17
+ - Behind the off-by-default `checkpoint.ontology.grok-backend` flag, `hq core checkpoint --backend auto` tries grok first for the payload company and falls back to the usual order when grok is missing or crashed last time. The detached ontology plan reads the flag and records it in the company state directory, so the foreground checkpoint makes no network call; a flag change applies from the checkpoint after the next plan.
18
+ - Behind the off-by-default `checkpoint.ontology.ignore-annotations` flag, the checkpoint ontology plan gives the model a copy of each knowledge input without the old cloud annotator's `entities:` front-matter list and entity links, which were mostly wrong and which the model copied.
19
+
5
20
  ## [5.208.0] — 2026-09-26
6
21
 
7
22
  ### Fixed
@@ -71,9 +71,17 @@ export declare function writeStamps(liveRoot: string, sessionId: string | undefi
71
71
  * claude session keeps using claude and a codex session keeps using codex
72
72
  * (same-engine continuity), then the fallback priority claude -> codex -> grok
73
73
  * for whatever the caller is not. Deterministic and env-only, so it is unit
74
- * tested directly.
74
+ * tested directly. `preferGrok` (the company's grok-backend flag) puts grok
75
+ * ahead of that order and leaves the rest as the fallback.
75
76
  */
76
- export declare function autoBackendPreference(): SpawnableBackend[];
77
+ export declare function autoBackendPreference(preferGrok?: boolean): SpawnableBackend[];
78
+ /**
79
+ * Whether the payload company's last ontology plan recorded
80
+ * checkpoint.ontology.grok-backend as on. Reads only the local state file the
81
+ * detached sibling writes, so a foreground checkpoint never waits on the flag
82
+ * registry; a flag change takes effect from the checkpoint after the next plan.
83
+ */
84
+ export declare function companyPrefersGrok(liveRoot: string, companySlug: string | null | undefined): boolean;
77
85
  /**
78
86
  * Whether the maintenance sibling may run at all. The process environment wins
79
87
  * over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
@@ -15,6 +15,8 @@ import { resolveLiveRoot } from "../utils/hq-roots.js";
15
15
  import { peekIdToken } from "../utils/id-token.js";
16
16
  import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
17
17
  import { checkpointStampWriteErrorFor, isCheckpointStampWriteError, } from "../utils/checkpoint-stamp-write-error.js";
18
+ import { readBackendPreference } from "../lib/checkpoint-ontology/plan.js";
19
+ import { resolveOntologyCompany } from "../lib/checkpoint-ontology/company.js";
18
20
  const DEFAULT_TRIGGER = "stop-gate";
19
21
  const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
20
22
  // Pinned by operator directive 2026-09-15; change defaults here deliberately.
@@ -672,13 +674,34 @@ function callerBackend() {
672
674
  * claude session keeps using claude and a codex session keeps using codex
673
675
  * (same-engine continuity), then the fallback priority claude -> codex -> grok
674
676
  * for whatever the caller is not. Deterministic and env-only, so it is unit
675
- * tested directly.
677
+ * tested directly. `preferGrok` (the company's grok-backend flag) puts grok
678
+ * ahead of that order and leaves the rest as the fallback.
676
679
  */
677
- export function autoBackendPreference() {
680
+ export function autoBackendPreference(preferGrok = false) {
678
681
  const caller = callerBackend();
679
- if (!caller)
680
- return [...FALLBACK_BACKEND_ORDER];
681
- return [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)];
682
+ const order = caller
683
+ ? [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)]
684
+ : [...FALLBACK_BACKEND_ORDER];
685
+ return preferGrok ? ["grok", ...order.filter((name) => name !== "grok")] : order;
686
+ }
687
+ /**
688
+ * Whether the payload company's last ontology plan recorded
689
+ * checkpoint.ontology.grok-backend as on. Reads only the local state file the
690
+ * detached sibling writes, so a foreground checkpoint never waits on the flag
691
+ * registry; a flag change takes effect from the checkpoint after the next plan.
692
+ */
693
+ export function companyPrefersGrok(liveRoot, companySlug) {
694
+ const resolved = resolveOntologyCompany(liveRoot, companySlug);
695
+ if (!resolved.ok)
696
+ return false;
697
+ try {
698
+ return readBackendPreference(liveRoot, resolved.company);
699
+ }
700
+ catch (error) {
701
+ // A damaged state file must not stop the checkpoint; the usual order applies.
702
+ printError(`checkpoint: ignoring unreadable backend preference for ${resolved.company.slug}: ${error.message}`);
703
+ return false;
704
+ }
682
705
  }
683
706
  function settingsEnvValue(liveRoot, key) {
684
707
  // settings.local.json wins, matching Claude Code's own merge order.
@@ -767,12 +790,12 @@ function assertKnownBackend(requested) {
767
790
  usage(`checkpoint: unknown backend: ${value}`);
768
791
  return value;
769
792
  }
770
- function resolveBackend(requested, liveRoot) {
793
+ function resolveBackend(requested, liveRoot, company) {
771
794
  const value = assertKnownBackend(requested);
772
795
  // An explicitly named backend is honoured as given; only `auto` shops around.
773
796
  if (value !== "auto")
774
797
  return value;
775
- const available = autoBackendPreference().filter((name) => backendOnPath(name) && backendResponds(name));
798
+ const available = autoBackendPreference(companyPrefersGrok(liveRoot, company)).filter((name) => backendOnPath(name) && backendResponds(name));
776
799
  if (available.length === 0)
777
800
  return "none";
778
801
  const crashed = crashedBackends(path.join(liveRoot, "workspace", "checkpoints", "sibling"));
@@ -1073,7 +1096,7 @@ function runCheckpoint(options, command, group) {
1073
1096
  const siblingOptOut = options.agent === false || !siblingEnabledBySetting(liveRoot);
1074
1097
  // Validated even when nothing will spawn, so `--backend bogus` is still an error.
1075
1098
  const requestedBackend = assertKnownBackend(options.backend);
1076
- const backend = siblingOptOut ? "none" : resolveBackend(requestedBackend, liveRoot);
1099
+ const backend = siblingOptOut ? "none" : resolveBackend(requestedBackend, liveRoot, input.company);
1077
1100
  const now = new Date();
1078
1101
  const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
1079
1102
  const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=lanes-drain-hook.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { runDrainHook } from "./lib/lanes/dropbox.js";
2
+ function parseHookInput(text) {
3
+ if (text.trim() === "")
4
+ return {};
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(text);
8
+ }
9
+ catch (err) {
10
+ throw new Error(`invalid_hook_input_json: ${err instanceof Error ? err.message : String(err)}`);
11
+ }
12
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
13
+ throw new Error("invalid_hook_input_shape: expected a JSON object");
14
+ }
15
+ const value = parsed;
16
+ for (const key of ["transcript_path", "session_id", "hook_event_name"]) {
17
+ if (value[key] !== undefined && value[key] !== null && typeof value[key] !== "string") {
18
+ throw new Error(`invalid_hook_input_field: ${key} must be a string`);
19
+ }
20
+ }
21
+ return {
22
+ ...(typeof value.transcript_path === "string" ? { transcript_path: value.transcript_path } : {}),
23
+ ...(typeof value.session_id === "string" ? { session_id: value.session_id } : {}),
24
+ ...(typeof value.hook_event_name === "string" ? { hook_event_name: value.hook_event_name } : {}),
25
+ };
26
+ }
27
+ async function main() {
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin)
30
+ chunks.push(Buffer.from(chunk));
31
+ const text = Buffer.concat(chunks).toString("utf8");
32
+ let input = {};
33
+ let inputError = "";
34
+ try {
35
+ input = parseHookInput(text);
36
+ }
37
+ catch (err) {
38
+ inputError = `hq-lanes-drain: ${err instanceof Error ? err.message : String(err)}\n`;
39
+ }
40
+ const result = await runDrainHook(process.env, input);
41
+ if (result.stdout)
42
+ process.stdout.write(result.stdout);
43
+ if (inputError || result.stderr)
44
+ process.stderr.write(`${inputError}${result.stderr}`);
45
+ process.exitCode = result.exitCode;
46
+ }
47
+ main().catch((err) => {
48
+ process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
49
+ // A hook runner failure must not stop the provider's requested tool call.
50
+ process.exitCode = 0;
51
+ });
52
+ //# sourceMappingURL=lanes-drain-hook.js.map
@@ -12,6 +12,13 @@ import { type OntologyCompany } from "./company.js";
12
12
  export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
13
13
  export declare const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
14
14
  export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
15
+ export declare const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annotations";
16
+ /**
17
+ * With `--backend auto`, `hq core checkpoint` tries grok first for this
18
+ * company. Read here, in the detached sibling, and recorded in the company's
19
+ * state directory, so the foreground checkpoint makes no network call.
20
+ */
21
+ export declare const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
15
22
  export declare const PLAN_FILE = "ontology-plan.json";
16
23
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
17
24
  export declare const REPORT_FILE = "ontology-report.json";
@@ -59,6 +66,8 @@ export interface PlanInput {
59
66
  } | null;
60
67
  /** For signals: `entity_refs` items with no entity file yet. */
61
68
  missing_refs?: string[];
69
+ /** Run-directory-relative copy of `path` with annotator entity lists removed; same line numbers. */
70
+ read_path?: string;
62
71
  }
63
72
  export interface OntologyPlan {
64
73
  schema: typeof PLAN_SCHEMA;
@@ -112,9 +121,13 @@ export type PlanOutcome = {
112
121
  planPath: string;
113
122
  companyBrief?: CompanyBriefResult;
114
123
  };
124
+ export declare const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
115
125
  export declare function ledgerPath(hqRoot: string, company: OntologyCompany): string;
116
126
  export declare function readLedger(hqRoot: string, company: OntologyCompany): Ledger | null;
117
127
  export declare function writeLedger(hqRoot: string, company: OntologyCompany, ledger: Ledger): void;
128
+ export declare function backendPreferencePath(hqRoot: string, company: OntologyCompany): string;
129
+ /** The grok-backend flag value the last plan recorded; false when no plan has recorded one. */
130
+ export declare function readBackendPreference(hqRoot: string, company: OntologyCompany): boolean;
118
131
  /** Company-relative paths of the payload's touched files that are inside this company. */
119
132
  export declare function sessionTouched(hqRoot: string, company: OntologyCompany, files: unknown): Set<string>;
120
133
  /** 1-based line ranges, each at most `chunkTokens` tokens (bytes / 4). */
@@ -130,4 +143,13 @@ export declare function refreshLedger(company: OntologyCompany, existing: Ledger
130
143
  };
131
144
  /** Plan this run. Never throws for a skip; returns the reason instead. */
132
145
  export declare function planOntologyRun(options: PlanOptions): Promise<PlanOutcome>;
146
+ /**
147
+ * The cloud annotator wrote an `entities:` list into knowledge front matter and
148
+ * `[[ontology/entities/...]]` links into the text. Most are wrong, and a model
149
+ * told to ignore them still copies them (design section 18), so the model reads
150
+ * a copy without them. The list's lines are blanked rather than removed so the
151
+ * plan's line ranges still hold; a link becomes its label, or its last path
152
+ * segment. Returns null when the text has neither.
153
+ */
154
+ export declare function stripAnnotations(text: string): string | null;
133
155
  //# sourceMappingURL=plan.d.ts.map
@@ -16,6 +16,13 @@ import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } fro
16
16
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
17
17
  export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
18
18
  export const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
19
+ export const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annotations";
20
+ /**
21
+ * With `--backend auto`, `hq core checkpoint` tries grok first for this
22
+ * company. Read here, in the detached sibling, and recorded in the company's
23
+ * state directory, so the foreground checkpoint makes no network call.
24
+ */
25
+ export const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
19
26
  export const PLAN_FILE = "ontology-plan.json";
20
27
  export const CANDIDATES_FILE = "ontology-candidates.json";
21
28
  export const REPORT_FILE = "ontology-report.json";
@@ -27,6 +34,7 @@ export const BUDGET = {
27
34
  maxTokens: 60_000,
28
35
  chunkTokens: 12_000,
29
36
  };
37
+ export const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
30
38
  const CANDIDATE_INSTRUCTIONS = [
31
39
  `Write ${CANDIDATES_FILE} in the run directory as {"schema":"${CANDIDATES_SCHEMA}","candidates":[...]} and nothing else.`,
32
40
  "Each candidate: {input, type, name, aliases, confidence, evidence}. input is one of this plan's input ids; type is person, project, company or concept; name is the entity's full proper name; aliases are other names the input uses for it (at most 5); confidence is 0 to 1; evidence is a short quote from the input.",
@@ -61,6 +69,26 @@ export function writeLedger(hqRoot, company, ledger) {
61
69
  const target = ledgerPath(hqRoot, company);
62
70
  safeWriteFile(hqRoot, [stateRoot(hqRoot, company)], target, `${JSON.stringify(ledger, null, 2)}\n`);
63
71
  }
72
+ export function backendPreferencePath(hqRoot, company) {
73
+ return path.join(stateRoot(hqRoot, company), "backend.json");
74
+ }
75
+ /** The grok-backend flag value the last plan recorded; false when no plan has recorded one. */
76
+ export function readBackendPreference(hqRoot, company) {
77
+ let raw;
78
+ try {
79
+ raw = fs.readFileSync(backendPreferencePath(hqRoot, company), "utf8");
80
+ }
81
+ catch (error) {
82
+ if (error.code === "ENOENT")
83
+ return false;
84
+ throw error;
85
+ }
86
+ return JSON.parse(raw)?.prefer_grok === true;
87
+ }
88
+ function recordBackendPreference(hqRoot, company, preferGrok) {
89
+ const target = backendPreferencePath(hqRoot, company);
90
+ safeWriteFile(hqRoot, [stateRoot(hqRoot, company)], target, `${JSON.stringify({ prefer_grok: preferGrok })}\n`);
91
+ }
64
92
  /** Company-relative paths of the payload's touched files that are inside this company. */
65
93
  export function sessionTouched(hqRoot, company, files) {
66
94
  const out = new Set();
@@ -172,13 +200,18 @@ export async function planOntologyRun(options) {
172
200
  if (!resolution.ok)
173
201
  return { status: "skipped", reason: resolution.reason };
174
202
  const company = resolution.company;
175
- if (!(await options.readFlag(ONTOLOGY_FLAG, company)))
203
+ if (!(await options.readFlag(ONTOLOGY_FLAG, company))) {
204
+ // A preference recorded while the ontology was on must not outlive it.
205
+ fs.rmSync(backendPreferencePath(hqRoot, company), { force: true });
176
206
  return { status: "skipped", reason: "flag off" };
207
+ }
177
208
  const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
178
209
  // Before the ledger, so a nothing-changed plan still records the brief.
179
210
  const companyBrief = (await options.readFlag(COMPANY_BRIEF_FLAG, company))
180
211
  ? writeCompanyBrief(hqRoot, company, options.now ?? new Date())
181
212
  : undefined;
213
+ const ignoreAnnotations = await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company);
214
+ recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
182
215
  const touched = sessionTouched(hqRoot, company, payload.files);
183
216
  const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
184
217
  const pending = Object.entries(ledger.entries)
@@ -192,9 +225,16 @@ export async function planOntologyRun(options) {
192
225
  remaining += 1;
193
226
  continue;
194
227
  }
195
- const lines = fs.readFileSync(path.join(company.dir, rel), "utf8").split("\n");
228
+ const text = fs.readFileSync(path.join(company.dir, rel), "utf8");
229
+ const lines = text.split("\n");
196
230
  const chunks = chunkLines(lines.join("\n"), BUDGET.chunkTokens);
197
231
  const kind = rel.startsWith("signals/") ? "signal" : "knowledge";
232
+ const cleaned = ignoreAnnotations && kind === "knowledge" ? stripAnnotations(text) : null;
233
+ const readPath = cleaned === null ? null : path.join(READ_COPY_DIR, rel);
234
+ if (readPath !== null) {
235
+ fs.mkdirSync(path.dirname(path.join(runDir, readPath)), { recursive: true });
236
+ fs.writeFileSync(path.join(runDir, readPath), cleaned);
237
+ }
198
238
  let index = entry.next_chunk ?? 0;
199
239
  for (; index < chunks.length; index += 1) {
200
240
  const [start, end] = chunks[index];
@@ -212,6 +252,7 @@ export async function planOntologyRun(options) {
212
252
  lines: chunks.length > 1 ? [start, end] : null,
213
253
  chunk: chunks.length > 1 ? { index, count: chunks.length } : null,
214
254
  ...(kind === "signal" ? { missing_refs: missingRefs(company.dir, rel) } : {}),
255
+ ...(readPath !== null ? { read_path: readPath } : {}),
215
256
  });
216
257
  tokens += cost;
217
258
  }
@@ -267,13 +308,47 @@ export async function planOntologyRun(options) {
267
308
  planned_tokens: tokens,
268
309
  },
269
310
  candidates_file: CANDIDATES_FILE,
270
- instructions: CANDIDATE_INSTRUCTIONS,
311
+ instructions: inputs.some((input) => input.read_path)
312
+ ? [...CANDIDATE_INSTRUCTIONS.slice(0, 3), READ_PATH_INSTRUCTION, ...CANDIDATE_INSTRUCTIONS.slice(3)]
313
+ : CANDIDATE_INSTRUCTIONS,
271
314
  };
272
315
  fs.mkdirSync(runDir, { recursive: true });
273
316
  const planPath = path.join(runDir, PLAN_FILE);
274
317
  fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
275
318
  return companyBrief ? { status: "planned", plan, planPath, companyBrief } : { status: "planned", plan, planPath };
276
319
  }
320
+ const READ_COPY_DIR = "ontology-inputs";
321
+ /**
322
+ * The cloud annotator wrote an `entities:` list into knowledge front matter and
323
+ * `[[ontology/entities/...]]` links into the text. Most are wrong, and a model
324
+ * told to ignore them still copies them (design section 18), so the model reads
325
+ * a copy without them. The list's lines are blanked rather than removed so the
326
+ * plan's line ranges still hold; a link becomes its label, or its last path
327
+ * segment. Returns null when the text has neither.
328
+ */
329
+ export function stripAnnotations(text) {
330
+ let out = text;
331
+ const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
332
+ if (front && /^entities:/m.test(front[1])) {
333
+ let inList = false;
334
+ const body = front[1]
335
+ .split("\n")
336
+ .map((line) => {
337
+ if (/^entities:/.test(line))
338
+ inList = true;
339
+ else if (inList && /^\S/.test(line))
340
+ inList = false;
341
+ return inList ? "" : line;
342
+ })
343
+ .join("\n");
344
+ const start = text.indexOf("\n") + 1;
345
+ out = text.slice(0, start) + body + text.slice(start + front[1].length);
346
+ }
347
+ out = out
348
+ .replace(/\[\[ontology\/entities\/[^\]|\n]*\|([^\]\n]*)\]\]/g, "$1")
349
+ .replace(/\[\[ontology\/entities\/(?:[^\]\n]*\/)?([^\]/\n]*)\]\]/g, "$1");
350
+ return out === text ? null : out;
351
+ }
277
352
  const SESSION_TEXT_MAX_BYTES = 8_000;
278
353
  /** The session summary, decisions and learnings from the payload, capped. */
279
354
  function sessionContent(payload) {
@@ -25,7 +25,7 @@
25
25
  * transcript is read only for `compact_boundary` system records, and only
26
26
  * their `compactMetadata.preTokens` is kept.
27
27
  */
28
- import type { LaneCompactionRecord, LaneRecord, LaneRunUsage } from "./types.js";
28
+ import type { LaneCompactionRecord, LaneProvider, LaneRecord, LaneRunUsage } from "./types.js";
29
29
  export declare const COMPACTION_MECHANISM: "claude_print_compact";
30
30
  /** The whole prompt of the compaction turn. */
31
31
  export declare const COMPACTION_PROMPT = "/compact";
@@ -47,6 +47,20 @@ export interface CompactBoundaryProbe {
47
47
  count: number;
48
48
  last_pre_tokens: number | null;
49
49
  }
50
+ export interface ProviderCompactionDelta {
51
+ count: number;
52
+ next_offset: number;
53
+ replaced: boolean;
54
+ }
55
+ /** Read newly appended, newline-complete marker records after a saved byte offset. */
56
+ export declare function probeProviderCompactionsSince(transcriptPath: string, provider: LaneProvider, startOffset: number): ProviderCompactionDelta;
57
+ export declare function providerTranscriptSize(transcriptPath: string): number;
58
+ /**
59
+ * Read only provider compaction markers from a lane transcript. Claude writes
60
+ * `system/compact_boundary`; Codex rollout files write a top-level
61
+ * `compacted` record. Tool and prompt contents are never returned or logged.
62
+ */
63
+ export declare function probeProviderCompactions(transcriptPath: string | null, provider: LaneProvider): number;
50
64
  /** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
51
65
  export declare function findSessionTranscript(home: string, sessionId: string): string | null;
52
66
  /** Count compact boundaries in a transcript. Other lines are never parsed. */
@@ -49,6 +49,102 @@ export function compactionDue(lane, nowMs, thresholdMs) {
49
49
  threshold_ms: thresholdMs,
50
50
  };
51
51
  }
52
+ function isProviderMarkerLine(line, lineNumber, transcriptPath, provider) {
53
+ const needle = provider === "claude" ? "compact_boundary" : "compacted";
54
+ if (!line.includes(needle))
55
+ return false;
56
+ let record;
57
+ try {
58
+ const parsed = JSON.parse(line);
59
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
60
+ return false;
61
+ record = parsed;
62
+ }
63
+ catch (err) {
64
+ process.stderr.write(`hq lanes: skipped unparseable provider transcript marker line ${lineNumber} in ${transcriptPath}: ${err instanceof Error ? err.message : String(err)}\n`);
65
+ return false;
66
+ }
67
+ return ((provider === "claude" && record.type === "system" && record.subtype === "compact_boundary") ||
68
+ (provider === "codex" && record.type === "compacted"));
69
+ }
70
+ /** Read newly appended, newline-complete marker records after a saved byte offset. */
71
+ export function probeProviderCompactionsSince(transcriptPath, provider, startOffset) {
72
+ if (provider === "grok")
73
+ return { count: 0, next_offset: startOffset, replaced: false };
74
+ const fd = fs.openSync(transcriptPath, "r");
75
+ try {
76
+ const size = fs.fstatSync(fd).size;
77
+ if (startOffset > size)
78
+ return { count: 0, next_offset: size, replaced: true };
79
+ if (startOffset === size)
80
+ return { count: 0, next_offset: size, replaced: false };
81
+ const chunkSize = 1024 * 1024;
82
+ const lineParts = [];
83
+ let count = 0;
84
+ let position = startOffset;
85
+ let nextOffset = startOffset;
86
+ let lineNumber = 0;
87
+ while (position < size) {
88
+ const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position));
89
+ const read = fs.readSync(fd, chunk, 0, chunk.length, position);
90
+ if (read === 0)
91
+ break;
92
+ position += read;
93
+ let lineStart = 0;
94
+ for (let index = 0; index < read; index += 1) {
95
+ if (chunk[index] !== 0x0a)
96
+ continue;
97
+ lineParts.push(chunk.subarray(lineStart, index));
98
+ const line = Buffer.concat(lineParts).toString("utf8");
99
+ lineParts.length = 0;
100
+ lineNumber += 1;
101
+ if (isProviderMarkerLine(line, lineNumber, transcriptPath, provider))
102
+ count += 1;
103
+ lineStart = index + 1;
104
+ nextOffset = position - (read - lineStart);
105
+ }
106
+ if (lineStart < read)
107
+ lineParts.push(chunk.subarray(lineStart, read));
108
+ }
109
+ return {
110
+ count,
111
+ next_offset: nextOffset,
112
+ replaced: false,
113
+ };
114
+ }
115
+ finally {
116
+ fs.closeSync(fd);
117
+ }
118
+ }
119
+ export function providerTranscriptSize(transcriptPath) {
120
+ try {
121
+ return fs.statSync(transcriptPath).size;
122
+ }
123
+ catch (err) {
124
+ if (err.code === "ENOENT")
125
+ return 0;
126
+ throw err;
127
+ }
128
+ }
129
+ /**
130
+ * Read only provider compaction markers from a lane transcript. Claude writes
131
+ * `system/compact_boundary`; Codex rollout files write a top-level
132
+ * `compacted` record. Tool and prompt contents are never returned or logged.
133
+ */
134
+ export function probeProviderCompactions(transcriptPath, provider) {
135
+ if (transcriptPath === null)
136
+ return 0;
137
+ if (provider === "grok")
138
+ return 0;
139
+ try {
140
+ return probeProviderCompactionsSince(transcriptPath, provider, 0).count;
141
+ }
142
+ catch (err) {
143
+ if (err.code === "ENOENT")
144
+ return 0;
145
+ throw err;
146
+ }
147
+ }
52
148
  /** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
53
149
  export function findSessionTranscript(home, sessionId) {
54
150
  const projects = path.join(home, "projects");
@@ -8,12 +8,15 @@
8
8
  * overlap.
9
9
  */
10
10
  import * as fs from "node:fs";
11
- import { type LaneRecord, type LaneState, type QuestionRecord } from "./types.js";
11
+ import { probeProviderCompactionsSince } from "./compaction.js";
12
+ import { tryReadLane } from "./store.js";
13
+ import { type LaneCompanyRef, type LaneRecord, type LaneState, type QuestionRecord } from "./types.js";
12
14
  export declare const DROPBOX_DIR = "dropbox";
13
15
  export declare const INBOX_NAME = "inbox.jsonl";
14
16
  export declare const DRAINING_DIR = "draining";
15
17
  export declare const DELIVERED_DIR = "delivered";
16
18
  export declare const INFLIGHT_DIR = "inflight";
19
+ export { LANE_MESSAGE_COMPACTION_REPLAY_FLAG } from "./message-replay.js";
17
20
  export interface DropboxMessage {
18
21
  at?: string;
19
22
  message_id?: string;
@@ -45,6 +48,21 @@ export interface DrainDeps {
45
48
  canDrain?: () => boolean;
46
49
  /** Test seam: runs after this process has claimed and read its bytes. */
47
50
  afterRead?: () => void;
51
+ /** Prefix marks archives that belong to one provider turn. */
52
+ archivePrefix?: string;
53
+ }
54
+ export interface LaneDrainHookInput {
55
+ transcript_path?: string | null;
56
+ session_id?: string;
57
+ hook_event_name?: string;
58
+ }
59
+ export interface RunDrainHookDeps {
60
+ readFlag?: (flag: string, options: {
61
+ company?: LaneCompanyRef | null;
62
+ env: NodeJS.ProcessEnv;
63
+ }) => Promise<boolean>;
64
+ readLane?: typeof tryReadLane;
65
+ probeCompactionsSince?: typeof probeProviderCompactionsSince;
48
66
  }
49
67
  export declare function mintClaimId(): string;
50
68
  export declare function assertSafeLaneId(laneId: string): void;
@@ -81,7 +99,7 @@ export declare function formatOperatorInstruction(messages: DropboxMessage[]): s
81
99
  export declare function formatMonitorEvent(message: DropboxMessage): string;
82
100
  /** Keep event order while framing monitor output separately from operator messages. */
83
101
  export declare function formatDropboxMessages(messages: DropboxMessage[]): string;
84
- export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit"): string;
102
+ export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit", leadingNote?: string): string;
85
103
  export interface DrainHookResult {
86
104
  exitCode: number;
87
105
  stdout: string;
@@ -92,7 +110,7 @@ export interface DrainHookResult {
92
110
  * That no-op is load-bearing: this hook is registered in the grok home and runs
93
111
  * on every session on the box.
94
112
  */
95
- export declare function runDrainHook(env: NodeJS.ProcessEnv): DrainHookResult;
113
+ export declare function runDrainHook(env: NodeJS.ProcessEnv, input?: LaneDrainHookInput, deps?: RunDrainHookDeps): Promise<DrainHookResult>;
96
114
  /** Spec §6.5: only a still-running lane gets drop-box delivery. */
97
115
  export declare function laneAcceptsDropboxDelivery(lane: Pick<LaneRecord, "state">): boolean;
98
116
  /**
@@ -10,13 +10,18 @@
10
10
  import { randomBytes } from "node:crypto";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
13
14
  import { LanesError } from "./errors.js";
15
+ import { readLanesRegistryFlag } from "./registry-flag.js";
16
+ import { LANE_MESSAGE_COMPACTION_REPLAY_FLAG, MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, } from "./message-replay.js";
14
17
  import { LANES_RUNS_REL, readProcStarttime } from "./spawn.js";
18
+ import { tryReadLane } from "./store.js";
15
19
  export const DROPBOX_DIR = "dropbox";
16
20
  export const INBOX_NAME = "inbox.jsonl";
17
21
  export const DRAINING_DIR = "draining";
18
22
  export const DELIVERED_DIR = "delivered";
19
23
  export const INFLIGHT_DIR = "inflight";
24
+ export { LANE_MESSAGE_COMPACTION_REPLAY_FLAG } from "./message-replay.js";
20
25
  function defaultDrainDeps() {
21
26
  return {
22
27
  mkdirSync: fs.mkdirSync.bind(fs),
@@ -278,7 +283,7 @@ export function drainDropboxAtPath(box, depsInput = {}) {
278
283
  deps.afterRead?.();
279
284
  for (const name of claimed) {
280
285
  const src = path.join(inflightDir, name);
281
- const dest = path.join(delivered, `${deps.unique()}.${name}`);
286
+ const dest = path.join(delivered, `${deps.archivePrefix ?? ""}${deps.unique()}.${name}`);
282
287
  if (!tryRename(src, dest, deps)) {
283
288
  throw new LanesError("dropbox_unreadable", `Claimed drop box file disappeared before delivery archive: ${src}`, { path: src });
284
289
  }
@@ -346,20 +351,118 @@ export function formatDropboxMessages(messages) {
346
351
  flushOperatorMessages();
347
352
  return sections.join("\n\n");
348
353
  }
349
- export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse") {
354
+ export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse", leadingNote) {
355
+ const formatted = formatDropboxMessages(messages);
350
356
  return `${JSON.stringify({
351
357
  hookSpecificOutput: {
352
358
  hookEventName,
353
- additionalContext: formatDropboxMessages(messages),
359
+ additionalContext: leadingNote ? `${leadingNote}\n\n${formatted}` : formatted,
354
360
  },
355
361
  })}\n`;
356
362
  }
363
+ function replayStatePath(hqRoot, laneId) {
364
+ return path.join(dropboxDir(hqRoot, laneId), MESSAGE_REPLAY_STATE_NAME);
365
+ }
366
+ function safeProviderTranscriptPath(transcriptPath, provider, env) {
367
+ if (provider === "grok")
368
+ return transcriptPath;
369
+ const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
370
+ if (!home) {
371
+ throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
372
+ }
373
+ if (!path.isAbsolute(transcriptPath) || path.extname(transcriptPath) !== ".jsonl") {
374
+ throw new LanesError("message_replay_transcript_path_invalid", `The ${provider} hook transcript path must be an absolute JSONL path`);
375
+ }
376
+ const configuredHomePath = path.resolve(home);
377
+ const homePath = fs.realpathSync(home);
378
+ const candidate = path.resolve(transcriptPath);
379
+ const expectedRoot = provider === "claude" ? "projects" : "sessions";
380
+ const assertWithinProviderHome = (root, resolvedTranscript, message) => {
381
+ const relative = path.relative(root, resolvedTranscript);
382
+ if (relative === "" ||
383
+ relative === ".." ||
384
+ relative.startsWith(`..${path.sep}`) ||
385
+ path.isAbsolute(relative) ||
386
+ relative.split(path.sep)[0] !== expectedRoot) {
387
+ throw new LanesError("message_replay_transcript_outside_provider_home", message);
388
+ }
389
+ };
390
+ try {
391
+ const realTranscript = fs.realpathSync(candidate);
392
+ assertWithinProviderHome(homePath, realTranscript, `The ${provider} hook transcript resolves outside its ${expectedRoot} directory`);
393
+ return realTranscript;
394
+ }
395
+ catch (err) {
396
+ if (err.code === "ENOENT") {
397
+ assertWithinProviderHome(configuredHomePath, candidate, `The ${provider} hook transcript is outside its configured ${expectedRoot} directory`);
398
+ return candidate;
399
+ }
400
+ throw err;
401
+ }
402
+ }
403
+ function readReplayState(filePath, turnId) {
404
+ const text = fs.readFileSync(filePath, "utf8");
405
+ let parsed;
406
+ try {
407
+ parsed = JSON.parse(text);
408
+ }
409
+ catch (err) {
410
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
411
+ }
412
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
413
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is not an object`);
414
+ }
415
+ const state = parsed;
416
+ if (state.schema_version !== 1 ||
417
+ state.turn_id !== turnId ||
418
+ (state.last_compaction_marker !== null &&
419
+ (!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
420
+ (state.transcript_offset !== undefined &&
421
+ (!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
422
+ (state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
423
+ (state.flag_enabled !== undefined && typeof state.flag_enabled !== "boolean")) {
424
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} does not match the current provider turn`);
425
+ }
426
+ // Older state files persisted this per-turn cache. Accept it, but remove it
427
+ // before the next write so the flag is refreshed for every compaction.
428
+ delete state.flag_enabled;
429
+ return state;
430
+ }
431
+ function writeReplayState(filePath, state) {
432
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
433
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
434
+ fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
435
+ try {
436
+ fs.renameSync(tempPath, filePath);
437
+ }
438
+ catch (err) {
439
+ fs.rmSync(tempPath, { force: true });
440
+ throw err;
441
+ }
442
+ }
443
+ function deliveredMessagesForTurn(hqRoot, laneId, turnId) {
444
+ const delivered = path.join(dropboxDir(hqRoot, laneId), DELIVERED_DIR);
445
+ let names;
446
+ try {
447
+ names = fs.readdirSync(delivered).filter((name) => name.startsWith(`${turnId}.`));
448
+ }
449
+ catch (err) {
450
+ if (err.code === "ENOENT")
451
+ return [];
452
+ throw err;
453
+ }
454
+ names.sort();
455
+ return names.flatMap((name) => readClaimedFile(path.join(delivered, name), defaultDrainDeps()));
456
+ }
457
+ function reportReplayFailure(err) {
458
+ return `hq-lanes-drain: message compaction replay failed: ${err instanceof Error ? err.message : String(err)}\n`;
459
+ }
357
460
  /**
358
461
  * Hook protocol. HQ_LANE_ID absent or empty → exit 0, empty stdout, empty stderr.
359
462
  * That no-op is load-bearing: this hook is registered in the grok home and runs
360
463
  * on every session on the box.
361
464
  */
362
- export function runDrainHook(env) {
465
+ export async function runDrainHook(env, input = {}, deps = {}) {
363
466
  const laneId = env.HQ_LANE_ID ?? "";
364
467
  if (laneId === "") {
365
468
  return { exitCode: 0, stdout: "", stderr: "" };
@@ -382,10 +485,81 @@ export function runDrainHook(env) {
382
485
  stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
383
486
  }
384
487
  try {
385
- const messages = drainDropbox(hqRoot, laneId);
386
- if (messages.length === 0) {
387
- return { exitCode: 0, stdout: "", stderr };
488
+ const turnId = env.HQ_LANE_TURN_ID ?? "";
489
+ const validTurnId = /^[A-Za-z0-9-]{1,80}$/.test(turnId);
490
+ const messages = drainDropbox(hqRoot, laneId, {
491
+ ...(validTurnId ? { archivePrefix: `${turnId}.` } : {}),
492
+ });
493
+ let replayed;
494
+ let replayNote;
495
+ if (validTurnId) {
496
+ try {
497
+ const statePath = replayStatePath(hqRoot, laneId);
498
+ let state;
499
+ try {
500
+ state = readReplayState(statePath, turnId);
501
+ }
502
+ catch (err) {
503
+ if (err.code !== "ENOENT")
504
+ throw err;
505
+ throw new LanesError("message_replay_state_missing", `Replay state ${statePath} was not initialized for this provider turn`);
506
+ }
507
+ if (input.transcript_path) {
508
+ const lane = (deps.readLane ?? tryReadLane)(hqRoot, laneId);
509
+ if (!lane) {
510
+ throw new LanesError("message_replay_lane_missing", `Lane ${laneId} could not be read for compaction replay`);
511
+ }
512
+ if (lane.provider === "grok") {
513
+ state.last_compaction_marker = 0;
514
+ }
515
+ else {
516
+ const transcriptPath = safeProviderTranscriptPath(input.transcript_path, lane.provider, env);
517
+ if (state.last_compaction_marker === null ||
518
+ state.transcript_path !== transcriptPath ||
519
+ state.transcript_offset === undefined) {
520
+ // Existing transcript history belongs to the turn's resume
521
+ // prompt. Start watching only records appended from this point.
522
+ state.last_compaction_marker = 0;
523
+ state.transcript_path = transcriptPath;
524
+ state.transcript_offset = providerTranscriptSize(transcriptPath);
525
+ }
526
+ else {
527
+ const delta = (deps.probeCompactionsSince ?? probeProviderCompactionsSince)(transcriptPath, lane.provider, state.transcript_offset);
528
+ state.transcript_offset = delta.next_offset;
529
+ if (delta.replaced) {
530
+ // A replaced or truncated transcript establishes a new
531
+ // baseline; it is not evidence of a fresh compaction.
532
+ state.last_compaction_marker = 0;
533
+ }
534
+ else if (delta.count > 0) {
535
+ state.last_compaction_marker += delta.count;
536
+ const flagEnabled = await (deps.readFlag ?? readLanesRegistryFlag)(LANE_MESSAGE_COMPACTION_REPLAY_FLAG, { company: lane.company, env });
537
+ if (flagEnabled) {
538
+ replayed = deliveredMessagesForTurn(hqRoot, laneId, turnId);
539
+ if (replayed.length > 0)
540
+ replayNote = MESSAGE_COMPACTION_REPLAY_NOTE;
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ writeReplayState(statePath, state);
547
+ }
548
+ catch (err) {
549
+ // Replay is advisory hook context. Report failures but let the provider
550
+ // tool call continue with whatever the ordinary inbox drain delivered.
551
+ stderr += reportReplayFailure(err);
552
+ }
388
553
  }
554
+ if (replayNote && replayed && replayed.length > 0) {
555
+ return {
556
+ exitCode: 0,
557
+ stdout: hookAdditionalContextJson(replayed, "PreToolUse", replayNote),
558
+ stderr,
559
+ };
560
+ }
561
+ if (messages.length === 0)
562
+ return { exitCode: 0, stdout: "", stderr };
389
563
  return { exitCode: 0, stdout: hookAdditionalContextJson(messages), stderr };
390
564
  }
391
565
  catch (err) {
@@ -20,6 +20,7 @@
20
20
  import type { LaneProvider } from "./types.js";
21
21
  export declare const DRAIN_HOOK_BASENAME = "hq-lanes-drain";
22
22
  export declare const DRAIN_HOOK_TIMEOUT_SECONDS = 30;
23
+ export declare function drainHookEntryPath(): string;
23
24
  export declare function lanesHookAssetsDir(): string;
24
25
  export declare const DRAIN_HOOK_REGISTRATION_KINDS: readonly ["hooks_dir", "settings_json", "config_toml"];
25
26
  export type DrainHookRegistrationKind = (typeof DRAIN_HOOK_REGISTRATION_KINDS)[number];
@@ -40,7 +41,7 @@ export interface DrainHookInstallResult {
40
41
  files: string[];
41
42
  registration: DrainHookRegistration;
42
43
  }
43
- export declare function drainHookCommand(shPath: string): string;
44
+ export declare function drainHookCommand(_shPath?: string): string;
44
45
  export declare function drainHookShPath(providerHome: string): string;
45
46
  export declare function renderDrainHookJson(shPath: string): string;
46
47
  export interface MergeResult {
@@ -53,7 +54,7 @@ export interface MergeResult {
53
54
  * and do nothing when a command hook with this exact command string is
54
55
  * already in the list. No `matcher` means every tool call.
55
56
  */
56
- export declare function mergeClaudeSettingsHook(existingText: string | null, command: string, settingsPath: string): MergeResult;
57
+ export declare function mergeClaudeSettingsHook(existingText: string | null, command: string, settingsPath: string, legacyCommand?: string): MergeResult;
57
58
  /**
58
59
  * Codex: hooks come from `config.toml` in the codex home. The drain is
59
60
  * appended as one `[[hooks.PreToolUse]]` table and `[features] hooks = true`
@@ -61,7 +62,7 @@ export declare function mergeClaudeSettingsHook(existingText: string | null, com
61
62
  * byte for byte; the result is re-parsed and must contain the registration,
62
63
  * otherwise nothing is written.
63
64
  */
64
- export declare function mergeCodexConfigHook(existingText: string | null, command: string, configPath: string): MergeResult;
65
+ export declare function mergeCodexConfigHook(existingText: string | null, command: string, configPath: string, legacyCommand?: string): MergeResult;
65
66
  export declare function drainHookRegistrationPath(provider: LaneProvider, providerHome: string): string;
66
67
  export interface DrainHookRegistrationProbe {
67
68
  kind: DrainHookRegistrationKind;
@@ -25,6 +25,12 @@ import { LanesError } from "./errors.js";
25
25
  import { parseLaneProvider } from "./types.js";
26
26
  export const DRAIN_HOOK_BASENAME = "hq-lanes-drain";
27
27
  export const DRAIN_HOOK_TIMEOUT_SECONDS = 30;
28
+ function shellQuote(value) {
29
+ return `'${value.replaceAll("'", "'\\''")}'`;
30
+ }
31
+ export function drainHookEntryPath() {
32
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/lanes-drain-hook.js");
33
+ }
28
34
  export function lanesHookAssetsDir() {
29
35
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../assets/lanes-hooks");
30
36
  }
@@ -38,7 +44,10 @@ export const PROVIDER_REGISTRATION_KIND = {
38
44
  codex: "config_toml",
39
45
  grok: "hooks_dir",
40
46
  };
41
- export function drainHookCommand(shPath) {
47
+ export function drainHookCommand(_shPath) {
48
+ return `${shellQuote(process.execPath)} ${shellQuote(drainHookEntryPath())}`;
49
+ }
50
+ function legacyDrainHookCommand(shPath) {
42
51
  return `bash ${shPath}`;
43
52
  }
44
53
  export function drainHookShPath(providerHome) {
@@ -87,6 +96,21 @@ function groupsContainCommand(groups, command) {
87
96
  return group.hooks.some((hook) => isPlainObject(hook) && hook.type === "command" && hook.command === command);
88
97
  });
89
98
  }
99
+ function removeCommandFromGroups(groups, command) {
100
+ let changed = false;
101
+ const next = groups.flatMap((group) => {
102
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
103
+ return [group];
104
+ const hooks = group.hooks.filter((hook) => !(isPlainObject(hook) &&
105
+ hook.type === "command" &&
106
+ hook.command === command));
107
+ if (hooks.length === group.hooks.length)
108
+ return [group];
109
+ changed = true;
110
+ return hooks.length === 0 ? [] : [{ ...group, hooks }];
111
+ });
112
+ return { groups: next, changed };
113
+ }
90
114
  export function renderDrainHookJson(shPath) {
91
115
  return `${JSON.stringify({
92
116
  hooks: {
@@ -110,7 +134,7 @@ export function renderDrainHookJson(shPath) {
110
134
  * and do nothing when a command hook with this exact command string is
111
135
  * already in the list. No `matcher` means every tool call.
112
136
  */
113
- export function mergeClaudeSettingsHook(existingText, command, settingsPath) {
137
+ export function mergeClaudeSettingsHook(existingText, command, settingsPath, legacyCommand) {
114
138
  let root = {};
115
139
  if (existingText !== null && existingText.trim() !== "") {
116
140
  try {
@@ -131,17 +155,22 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath) {
131
155
  if (!Array.isArray(pre)) {
132
156
  throw new LanesError("settings_unparseable", `${settingsPath} has a non-array "hooks.PreToolUse"; refusing to rewrite it`, { path: settingsPath });
133
157
  }
134
- if (groupsContainCommand(pre, command)) {
158
+ const migration = legacyCommand && legacyCommand !== command
159
+ ? removeCommandFromGroups(pre, legacyCommand)
160
+ : { groups: pre, changed: false };
161
+ if (groupsContainCommand(migration.groups, command) && !migration.changed) {
135
162
  return { text: existingText ?? "", changed: false };
136
163
  }
137
- const nextPre = [
138
- ...pre,
139
- {
140
- hooks: [
141
- { type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS },
142
- ],
143
- },
144
- ];
164
+ const nextPre = groupsContainCommand(migration.groups, command)
165
+ ? migration.groups
166
+ : [
167
+ ...migration.groups,
168
+ {
169
+ hooks: [
170
+ { type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS },
171
+ ],
172
+ },
173
+ ];
145
174
  const nextRoot = { ...root, hooks: { ...hooks, PreToolUse: nextPre } };
146
175
  return { text: `${JSON.stringify(nextRoot, null, 2)}\n`, changed: true };
147
176
  }
@@ -156,6 +185,127 @@ function tomlHooksFeatureEnabled(parsed) {
156
185
  function tomlHasCommand(parsed, command) {
157
186
  return isPlainObject(parsed.hooks) && groupsContainCommand(parsed.hooks.PreToolUse, command);
158
187
  }
188
+ function tomlHeader(line) {
189
+ const match = /^\s*(\[\[|\[)([^\]]+)(\]\]|\])\s*(?:#.*)?$/.exec(line);
190
+ if (!match) {
191
+ const trimmed = line.trimStart();
192
+ return trimmed.startsWith("[")
193
+ ? { path: "", array: trimmed.startsWith("[[") }
194
+ : undefined;
195
+ }
196
+ const array = match[1] === "[[";
197
+ if ((array && match[3] !== "]]") ||
198
+ (!array && match[3] !== "]")) {
199
+ return { path: "", array };
200
+ }
201
+ return { path: match[2].trim(), array };
202
+ }
203
+ function tomlCommandValue(line) {
204
+ const match = /^\s*command\s*=\s*(.+)$/.exec(line);
205
+ if (!match)
206
+ return undefined;
207
+ try {
208
+ const parsed = parseToml(`command = ${match[1]}\n`);
209
+ return isPlainObject(parsed) && typeof parsed.command === "string"
210
+ ? parsed.command
211
+ : undefined;
212
+ }
213
+ catch {
214
+ return undefined;
215
+ }
216
+ }
217
+ /** Remove old installer-created Codex command tables while retaining TOML text and operator hooks. */
218
+ function removeLegacyCodexHook(text, parsed, legacyCommand, configPath) {
219
+ if (!legacyCommand || legacyCommand === "")
220
+ return { text, changed: false };
221
+ const hooks = isPlainObject(parsed.hooks) ? parsed.hooks.PreToolUse : undefined;
222
+ const groups = Array.isArray(hooks) ? hooks : [];
223
+ const expectedCount = groups.reduce((total, group) => {
224
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
225
+ return total;
226
+ return total + group.hooks.filter((hook) => isPlainObject(hook) &&
227
+ hook.type === "command" &&
228
+ hook.command === legacyCommand).length;
229
+ }, 0);
230
+ if (expectedCount === 0)
231
+ return { text, changed: false };
232
+ const lines = text.split("\n");
233
+ const parentHeaders = lines.flatMap((line, index) => {
234
+ const header = tomlHeader(line);
235
+ return header?.array && header.path === "hooks.PreToolUse" ? [index] : [];
236
+ });
237
+ const wholeGroupIndexes = new Set();
238
+ if (parentHeaders.length === groups.length) {
239
+ groups.forEach((group, index) => {
240
+ if (isPlainObject(group) &&
241
+ Array.isArray(group.hooks) &&
242
+ group.hooks.length === 1 &&
243
+ isPlainObject(group.hooks[0]) &&
244
+ group.hooks[0].type === "command" &&
245
+ group.hooks[0].command === legacyCommand) {
246
+ wholeGroupIndexes.add(index);
247
+ }
248
+ });
249
+ }
250
+ const spans = [];
251
+ let parentGroupIndex = -1;
252
+ for (let index = 0; index < lines.length; index += 1) {
253
+ const header = tomlHeader(lines[index]);
254
+ if (header?.array && header.path === "hooks.PreToolUse") {
255
+ parentGroupIndex += 1;
256
+ if (!wholeGroupIndexes.has(parentGroupIndex))
257
+ continue;
258
+ let end = index + 1;
259
+ for (; end < lines.length; end += 1) {
260
+ const nextHeader = tomlHeader(lines[end]);
261
+ if (!nextHeader)
262
+ continue;
263
+ if (nextHeader.array && nextHeader.path === "hooks.PreToolUse")
264
+ break;
265
+ if (nextHeader.path.startsWith("hooks.PreToolUse."))
266
+ continue;
267
+ break;
268
+ }
269
+ spans.push({ start: index, end });
270
+ index = end - 1;
271
+ continue;
272
+ }
273
+ if (!header?.array || header.path !== "hooks.PreToolUse.hooks")
274
+ continue;
275
+ let end = index + 1;
276
+ let foundLegacyCommand = false;
277
+ for (; end < lines.length; end += 1) {
278
+ const nextHeader = tomlHeader(lines[end]);
279
+ if (!nextHeader) {
280
+ if (tomlCommandValue(lines[end]) === legacyCommand) {
281
+ foundLegacyCommand = true;
282
+ }
283
+ continue;
284
+ }
285
+ if (nextHeader.array &&
286
+ nextHeader.path === "hooks.PreToolUse.hooks") {
287
+ break;
288
+ }
289
+ if (nextHeader.path.startsWith("hooks.PreToolUse.hooks."))
290
+ continue;
291
+ break;
292
+ }
293
+ if (foundLegacyCommand)
294
+ spans.push({ start: index, end });
295
+ index = end - 1;
296
+ }
297
+ if (spans.length !== expectedCount) {
298
+ throw new LanesError("config_toml_migration_failed", `Could not safely remove all legacy lane drain hooks from ${configPath}; refusing to add a second drain`, { path: configPath, expected: expectedCount, found: spans.length });
299
+ }
300
+ for (const span of spans.reverse())
301
+ lines.splice(span.start, span.end - span.start);
302
+ const migrated = lines.join("\n");
303
+ const migratedConfig = parseTomlOrThrow(migrated, configPath, "after legacy hook removal");
304
+ if (tomlHasCommand(migratedConfig, legacyCommand)) {
305
+ throw new LanesError("config_toml_migration_failed", `Legacy lane drain hook remains in ${configPath}; refusing to add a second drain`, { path: configPath });
306
+ }
307
+ return { text: migrated, changed: true };
308
+ }
159
309
  function parseTomlOrThrow(text, configPath, stage) {
160
310
  let parsed;
161
311
  try {
@@ -203,15 +353,18 @@ function enableTomlHooksFeature(text) {
203
353
  * byte for byte; the result is re-parsed and must contain the registration,
204
354
  * otherwise nothing is written.
205
355
  */
206
- export function mergeCodexConfigHook(existingText, command, configPath) {
356
+ export function mergeCodexConfigHook(existingText, command, configPath, legacyCommand) {
207
357
  const original = existingText ?? "";
208
358
  const parsed = parseTomlOrThrow(original, configPath, "before merge");
209
- const hasCommand = tomlHasCommand(parsed, command);
210
- const featureOn = tomlHooksFeatureEnabled(parsed);
211
- if (hasCommand && featureOn) {
359
+ const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
360
+ const textAfterMigration = migration.text;
361
+ const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
362
+ const hasCommand = tomlHasCommand(current, command);
363
+ const featureOn = tomlHooksFeatureEnabled(current);
364
+ if (hasCommand && featureOn && !migration.changed) {
212
365
  return { text: original, changed: false };
213
366
  }
214
- let text = original;
367
+ let text = textAfterMigration;
215
368
  if (!featureOn)
216
369
  text = enableTomlHooksFeature(text);
217
370
  if (!hasCommand) {
@@ -286,8 +439,8 @@ function registerDrainHook(provider, providerHome, shPath) {
286
439
  }
287
440
  const existing = readTextIfExists(regPath);
288
441
  const merged = kind === "settings_json"
289
- ? mergeClaudeSettingsHook(existing, command, regPath)
290
- : mergeCodexConfigHook(existing, command, regPath);
442
+ ? mergeClaudeSettingsHook(existing, command, regPath, legacyDrainHookCommand(shPath))
443
+ : mergeCodexConfigHook(existing, command, regPath, legacyDrainHookCommand(shPath));
291
444
  if (!merged.changed) {
292
445
  return { kind, path: regPath, command, changed: false };
293
446
  }
@@ -0,0 +1,14 @@
1
+ export declare const LANE_MESSAGE_COMPACTION_REPLAY_FLAG = "lanes.message-compaction-replay";
2
+ export declare const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
3
+ export declare const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
4
+ export interface MessageReplayState {
5
+ schema_version: 1;
6
+ turn_id: string;
7
+ /** Null until the provider transcript is first observed in this turn. */
8
+ last_compaction_marker: number | null;
9
+ /** Last complete JSONL byte offset scanned in the provider transcript. */
10
+ transcript_offset?: number;
11
+ /** Canonical transcript path for this provider turn. */
12
+ transcript_path?: string;
13
+ }
14
+ //# sourceMappingURL=message-replay.d.ts.map
@@ -0,0 +1,4 @@
1
+ export const LANE_MESSAGE_COMPACTION_REPLAY_FLAG = "lanes.message-compaction-replay";
2
+ export const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
3
+ export const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
4
+ //# sourceMappingURL=message-replay.js.map
@@ -8,6 +8,7 @@
8
8
  * mutable fields are re-read from the lane record (spec §5.4).
9
9
  */
10
10
  import { spawn, spawnSync } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
11
12
  import * as fs from "node:fs";
12
13
  import * as path from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -25,6 +26,7 @@ import { resolveMachineIdentity, } from "./machine-identity.js";
25
26
  import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./store.js";
26
27
  import { copySessionFiles, planSessionCopy } from "./move-account.js";
27
28
  import { ensureSessionHome } from "./session-relink.js";
29
+ import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
28
30
  import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
29
31
  import { resolveAdmissionHost } from "./admission-host.js";
30
32
  import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
@@ -1750,6 +1752,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1750
1752
  ? providerAutoCompactTokens(config.providers.claude)
1751
1753
  : undefined;
1752
1754
  const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir);
1755
+ const laneTurnId = randomUUID();
1756
+ childEnv.HQ_LANE_TURN_ID = laneTurnId;
1753
1757
  const model = resolveLaneModel({
1754
1758
  cliModel: pinnedModel,
1755
1759
  profileModel,
@@ -2014,6 +2018,30 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2014
2018
  }
2015
2019
  return laneLaunchResult(claimedRecord);
2016
2020
  }
2021
+ // Each normal spawn/resume starts a fresh provider turn. The resume prompt
2022
+ // carries the prior message text, so only messages delivered after this
2023
+ // reset may be replayed if the new turn compacts.
2024
+ const replayStatePath = path.join(runDir, "dropbox", MESSAGE_REPLAY_STATE_NAME);
2025
+ const replayStateTempPath = `${replayStatePath}.${process.pid}.${laneTurnId}.tmp`;
2026
+ try {
2027
+ fs.mkdirSync(path.dirname(replayStatePath), { recursive: true });
2028
+ fs.writeFileSync(replayStateTempPath, `${JSON.stringify({
2029
+ schema_version: 1,
2030
+ turn_id: laneTurnId,
2031
+ last_compaction_marker: null,
2032
+ })}\n`, { mode: 0o600 });
2033
+ fs.renameSync(replayStateTempPath, replayStatePath);
2034
+ }
2035
+ catch (err) {
2036
+ try {
2037
+ fs.rmSync(replayStateTempPath, { force: true });
2038
+ }
2039
+ catch (cleanupErr) {
2040
+ process.stderr.write(`hq lanes: compaction replay state temp cleanup failed for ${lane.lane_id}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}\n`);
2041
+ }
2042
+ delete childEnv.HQ_LANE_TURN_ID;
2043
+ process.stderr.write(`hq lanes: compaction replay disabled for ${lane.lane_id}; failed to reset turn state: ${err instanceof Error ? err.message : String(err)}\n`);
2044
+ }
2017
2045
  const workerOutPath = path.join(logsDir, "worker.out");
2018
2046
  const workerOutOffset = fileSizeOrZero(workerOutPath);
2019
2047
  let workerPid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.208.0",
3
+ "version": "5.210.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {