Skip to content

Commit 188eaa3

Browse files
authored
fix(coding-agents): honor retainSessions in the hook harnesses (#3596) (#3607)
* fix(coding-agents): honor retainSessions in the hook harnesses (#3596) `retainSessions` was parsed, defaulted, env-mapped and accepted as a known config key, but only `RuntimeCore` (opencode, Kilo, Cline, Prime Agent, dsh) ever read it. The shared Stop-hook flow behind every hook harness — claude-code, codex, cursor-cli, copilot-cli, devin-cli, grok-build, antigravity-cli — checked `disabled` twice and `retainSessions` never, so `retainSessions: false` (global, per-harness or in a `banks.<id>` section) wrote the transcript back anyway. The comment claimed this was deliberate while the docs sold the flag as a general write-back opt-out, including a per-bank example. Gate the write-back in `runRetainHook`, after `applyBankConfig` so a bank section can flip it either way, and before `ensureDaemon` — a session that writes nothing has no reason to bring a server up. A `retain_disabled` diag record replaces the `retain_ok` that used to appear, so the opt-out is verifiable in the diagnostic log. `deepen`'s conversation-history import is the same door one session later: it reads the harness's own history files and files them as `chat:<id>`. Honoring the flag in only one of the two places would have left the opt-out cosmetic, so it skips the import too. Git ingest, seeding, knowledge pages, recall and the memory tools are all untouched — that separation is what distinguishes this flag from the `disabled` kill switch. Tests: four end-to-end `runRetainHook` cases (default writes; global false writes nothing and builds no client; a bank override opts one repo out; a bank override re-enables under a global opt-out), two of which fail against the pre-fix code. Plus a family-wide structural guard in the shape of `daemon.test.ts`'s "every harness entrypoint reaches a daemon": every module calling `retainLiveSession` or `ingestChats` must consult the flag. The path that forgot is by definition the one with no test, so the guard is asserted over the whole family rather than per-harness. * chore(docs): re-sync the coding-agents page after the README reflow
1 parent 39de397 commit 188eaa3

8 files changed

Lines changed: 196 additions & 21 deletions

File tree

hindsight-docs/docs-integrations/coding-agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ hook by Codex...), so one shared config serves several agents side by side:
392392
| `codebaseSurvey` | `true` | SessionStart: headless survey of a cold repo's structure, run under the current harness's own CLI (claude/codex/antigravity/opencode), falling back to any available agent |
393393
| `surveyModel` | `haiku` | model for the survey — Claude recipe only (`claude -p --model`); other agents use their configured default |
394394
| `surveyBudgetUsd` | `2` | survey spend cap — Claude recipe only (`claude -p --max-budget-usd`); other agents rely on their read-only sandbox |
395-
| `retainSessions` | `true` | plugin-harness write-back (opencode, Kilo): async upsert of the session transcript every turn, plus an idle flush that captures the reply the per-turn pass can't see (set `false` to opt out; hook harnesses always write on Stop) |
395+
| `retainSessions` | `true` | session write-back, honored by every harness: hook harnesses write the transcript on Stop, plugin harnesses (opencode, Kilo) upsert it every turn plus an idle flush that captures the reply the per-turn pass can't see. Set `false` — globally, per harness, or per bank — to stop writing transcripts (the background history import stops with it) while recall, git ingest and the memory tools keep working |
396396
| `maxParallelRetains` | `10` | cap on concurrent retain-related requests: drain()'s per-op polls plus deepen's chat/git retain pools. The API rate-limits bursts, not single requests — if you see 429s, lower this rather than raising it |
397397
| `logLevel` | `"info"` | plugin-log verbosity (`"debug"` \| `"info"` \| `"warn"` \| `"error"`); `HINDSIGHT_LOG_LEVEL` env overrides |
398398
| `gitIngest` | `"message"` | git depth for seeding AND staying current (same engine): `"message"` = commit messages only (one doc, re-upserted when HEAD moves); `"full"` = messages + per-commit full diffs (progressive, newest first); `"none"` = git off |

hindsight-integrations/coding-agents/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ hook by Codex...), so one shared config serves several agents side by side:
385385
| `codebaseSurvey` | `true` | SessionStart: headless survey of a cold repo's structure, run under the current harness's own CLI (claude/codex/antigravity/opencode), falling back to any available agent |
386386
| `surveyModel` | `haiku` | model for the survey — Claude recipe only (`claude -p --model`); other agents use their configured default |
387387
| `surveyBudgetUsd` | `2` | survey spend cap — Claude recipe only (`claude -p --max-budget-usd`); other agents rely on their read-only sandbox |
388-
| `retainSessions` | `true` | plugin-harness write-back (opencode, Kilo): async upsert of the session transcript every turn, plus an idle flush that captures the reply the per-turn pass can't see (set `false` to opt out; hook harnesses always write on Stop) |
388+
| `retainSessions` | `true` | session write-back, honored by every harness: hook harnesses write the transcript on Stop, plugin harnesses (opencode, Kilo) upsert it every turn plus an idle flush that captures the reply the per-turn pass can't see. Set `false` — globally, per harness, or per bank — to stop writing transcripts (the background history import stops with it) while recall, git ingest and the memory tools keep working |
389389
| `maxParallelRetains` | `10` | cap on concurrent retain-related requests: drain()'s per-op polls plus deepen's chat/git retain pools. The API rate-limits bursts, not single requests — if you see 429s, lower this rather than raising it |
390390
| `logLevel` | `"info"` | plugin-log verbosity (`"debug"` \| `"info"` \| `"warn"` \| `"error"`); `HINDSIGHT_LOG_LEVEL` env overrides |
391391
| `gitIngest` | `"message"` | git depth for seeding AND staying current (same engine): `"message"` = commit messages only (one doc, re-upserted when HEAD moves); `"full"` = messages + per-commit full diffs (progressive, newest first); `"none"` = git off |

hindsight-integrations/coding-agents/skill/SKILL.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ Layering, later wins: defaults → file → `harnesses.<name>` → `banks.<resol
7777
```
7878

7979
Key behavioral fields (any of them valid per-harness or per-bank): `disabled`,
80-
`retainSessions` (write-back opt-out), `gitIngest`, `reflectTimeoutMs` (AUTOMATIC session reflect,
81-
default 120000; hooks cap at 25s), `reflectToolTimeoutMs`/`reflectBudget` (the agent-invoked
82-
`hindsight_reflect` tool: default 330000 — above the server's 300s reflect wall timeout — and
83-
"high"), `autoReflect` (true; false = no injected first-prompt synthesis — the agent is instead
84-
told to call `hindsight_reflect` on new goals), `pageRefreshEveryTurns` (10),
80+
`retainSessions` (transcript write-back opt-out, history import included — recall and git ingest keep working), `gitIngest`,
81+
`reflectTimeoutMs` (AUTOMATIC session reflect, default 120000; hooks cap at 25s),
82+
`reflectToolTimeoutMs`/`reflectBudget` (the agent-invoked `hindsight_reflect` tool: default 330000 —
83+
above the server's 300s reflect wall timeout — and "high"), `autoReflect` (true; false = no injected
84+
first-prompt synthesis — the agent is instead told to call `hindsight_reflect` on new goals),
85+
`pageRefreshEveryTurns` (10),
8586
`pageTriggerType`/`pageTriggerCron` (when NEW knowledge pages refresh: `auto-refresh` (default) after
8687
each consolidation, `cron` on a schedule, `manual` never — existing pages keep the trigger they were
8788
created with), `autoSeed`/`seedLimit` (true/300),

hindsight-integrations/coding-agents/src/core/config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ export interface RawConfig {
7777
optInPaths?: string[];
7878
harness?: string; // runtime adapter (default "opencode")
7979
disabled?: boolean; // hard off-switch — inert plugin, for a no-memory baseline (default false)
80-
retainSessions?: boolean; // opencode plugin write-back (default true; set false to opt out). Hook harnesses always write back on Stop and ignore this flag.
80+
/** Session transcript write-back (default true; set false to opt out). Honored by every
81+
* harness: the hook harnesses' Stop write-back and the persistent plugins' per-turn
82+
* cadence alike. Gates ONLY the transcript — recall, git ingest, seeding and the memory
83+
* tools keep working (that is `disabled`'s job). */
84+
retainSessions?: boolean;
8185
/** Cap on concurrent retain-related requests the client sends to the API (default 10):
8286
* drain()'s per-operation polls and deepen's chat/git retain pools. A single request returning
8387
* 200 while bursts get 429s means the server is rate-limiting concurrency, not total volume —
@@ -299,7 +303,7 @@ export function resolveConfig(raw: RawConfig = {}): Config {
299303
: [],
300304
harness: raw.harness ?? "opencode",
301305
disabled: raw.disabled ?? false,
302-
retainSessions: raw.retainSessions ?? true, // opencode: write back by default (parity with hook-harness Stop)
306+
retainSessions: raw.retainSessions ?? true, // write sessions back by default, every harness
303307
maxParallelRetains: raw.maxParallelRetains || 10,
304308
reflectTimeoutMs: raw.reflectTimeoutMs || 120000,
305309
// Inherit an explicitly-raised reflectTimeoutMs (that is what users reaching for a longer

hindsight-integrations/coding-agents/src/core/retain-hook.test.ts

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
3-
import { join } from "node:path";
3+
import { basename, join } from "node:path";
4+
import { fileURLToPath } from "node:url";
45
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { deriveBankId } from "./bank";
7+
import { type RawConfig, resolveConfig } from "./config";
58
import type { HindsightClient } from "./hindsight";
69
import { buildRetain, runRetainHook } from "./retain-hook";
710
import { memoryCursorStore, type RetainCursorStore } from "./retain-cursor";
811

12+
/** The Stop event `runRetainHook` reads from fd 0; every other read stays real. */
13+
let stdin = "";
14+
vi.mock("node:fs", async (importOriginal) => {
15+
const actual = await importOriginal<typeof import("node:fs")>();
16+
return {
17+
...actual,
18+
readFileSync: (target: unknown, ...rest: unknown[]) =>
19+
target === 0 ? stdin : (actual.readFileSync as (...a: unknown[]) => unknown)(target, ...rest),
20+
};
21+
});
22+
23+
/** What the hook's `loadConfig` returns — the real resolver over a per-test raw config, so bank
24+
* overrides and defaults behave exactly as they do against a real config file. */
25+
let rawConfig: RawConfig = {};
26+
vi.mock("./config", async (importOriginal) => {
27+
const actual = await importOriginal<typeof import("./config")>();
28+
return { ...actual, loadConfig: () => actual.resolveConfig(rawConfig) };
29+
});
30+
931
let root: string;
1032
let file: string;
1133

@@ -240,3 +262,129 @@ describe("buildRetain — incremental write-back across Stop hooks", () => {
240262
expect((retain.mock.calls[2][0] as string).split("\n")).toHaveLength(5);
241263
});
242264
});
265+
266+
/**
267+
* The `retainSessions: false` opt-out (#3596): the flag was parsed and env-mapped but no Stop-hook
268+
* path ever read it, so hook harnesses wrote every transcript back regardless. These drive the
269+
* real `runRetainHook` — stdin event in, config resolved through the real loader — because the bug
270+
* was precisely a missing wire between the two, which a test of either half alone cannot see.
271+
*/
272+
describe("runRetainHook honors retainSessions", () => {
273+
const event = () => ({
274+
// A fresh session id per test: `sessionRootDir` caches a session's starting directory in /tmp
275+
// for the life of the session, so reusing one id would pin every test to the first test's temp
276+
// dir — and so to the wrong bank.
277+
session_id: `sess-gate-${basename(root)}`,
278+
transcript_path: file,
279+
cwd: root,
280+
});
281+
282+
const spec = {
283+
harness: "claude-code",
284+
hostTimeoutSec: 60,
285+
parse: (ev: Record<string, unknown>) => ({
286+
sessionId: ev.session_id as string,
287+
transcriptPath: ev.transcript_path as string,
288+
cwd: ev.cwd as string,
289+
}),
290+
};
291+
292+
beforeEach(() => {
293+
vi.stubEnv("HINDSIGHT_DIAG_FILE", join(root, "diag.log"));
294+
rawConfig = {};
295+
writeFileSync(
296+
file,
297+
JSON.stringify({
298+
type: "user",
299+
timestamp: "2026-01-01T00:00:00Z",
300+
message: { role: "user", content: "we use zod for validation" },
301+
})
302+
);
303+
stdin = JSON.stringify(event());
304+
});
305+
306+
afterEach(() => {
307+
vi.unstubAllEnvs();
308+
});
309+
310+
const stubClient = () => {
311+
const retain = vi.fn().mockResolvedValue(undefined);
312+
const makeClient = vi.fn(() => ({
313+
retain,
314+
supportsIdempotentRetain: async () => false,
315+
})) as unknown as Parameters<typeof runRetainHook>[1];
316+
return { retain, makeClient };
317+
};
318+
319+
it("writes the transcript back by default", async () => {
320+
const { retain, makeClient } = stubClient();
321+
await runRetainHook(spec, makeClient);
322+
expect(retain).toHaveBeenCalledTimes(1);
323+
});
324+
325+
it("retainSessions: false -> no write-back, and no client is even built", async () => {
326+
rawConfig = { retainSessions: false };
327+
const { retain, makeClient } = stubClient();
328+
await runRetainHook(spec, makeClient);
329+
expect(retain).not.toHaveBeenCalled();
330+
expect(makeClient).not.toHaveBeenCalled();
331+
});
332+
333+
it("a banks.<id> override opts one repo out while the global default still writes", async () => {
334+
const bankId = deriveBankId(resolveConfig(), root, spec.harness, root);
335+
rawConfig = { banks: { [bankId]: { retainSessions: false } } };
336+
const optedOut = stubClient();
337+
await runRetainHook(spec, optedOut.makeClient);
338+
expect(optedOut.retain).not.toHaveBeenCalled();
339+
340+
rawConfig = { banks: { "some-other-bank": { retainSessions: false } } };
341+
const untouched = stubClient();
342+
await runRetainHook(spec, untouched.makeClient);
343+
expect(untouched.retain).toHaveBeenCalledTimes(1);
344+
});
345+
346+
it("a banks.<id> override turns write-back back on under a global opt-out", async () => {
347+
const bankId = deriveBankId(resolveConfig(), root, spec.harness, root);
348+
rawConfig = { retainSessions: false, banks: { [bankId]: { retainSessions: true } } };
349+
const { retain, makeClient } = stubClient();
350+
await runRetainHook(spec, makeClient);
351+
expect(retain).toHaveBeenCalledTimes(1);
352+
});
353+
});
354+
355+
/**
356+
* Family-wide guard, in the shape of `daemon.test.ts`'s "every harness entrypoint reaches a
357+
* daemon". #3596 was not a broken line of code but a MISSING one: the persistent-plugin path
358+
* honored `retainSessions` and the hook path silently didn't, and no test failed because the path
359+
* that forgot is by definition the one nobody wrote a test for. So assert over the whole family:
360+
* every module that puts a conversation in the bank must consult the flag — the live write-back
361+
* (`retainLiveSession`) and deepen's history import (`ingestChats`) alike, since either one alone
362+
* would leave the opt-out cosmetic.
363+
*/
364+
describe("every session write-back path honors retainSessions", () => {
365+
const SRC = fileURLToPath(new URL("..", import.meta.url));
366+
const WRITERS = ["retainLiveSession(", "ingestChats("];
367+
/** The modules that DEFINE the writers — the callers are what must gate. */
368+
const DEFINITIONS = ["core/chat.ts"];
369+
370+
function sourceFiles(dir: string, prefix = ""): string[] {
371+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
372+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
373+
if (entry.isDirectory())
374+
return entry.name === "e2e" ? [] : sourceFiles(join(dir, entry.name), rel);
375+
return entry.name.endsWith(".ts") && !entry.name.includes(".test.") ? [rel] : [];
376+
});
377+
}
378+
379+
it("has no conversation-writing module that ignores the flag", () => {
380+
const ungated = sourceFiles(SRC).filter((rel) => {
381+
if (DEFINITIONS.includes(rel)) return false;
382+
const src = readFileSync(join(SRC, rel), "utf8");
383+
if (!WRITERS.some((w) => src.includes(w))) return false;
384+
// `writeBackEnabled` is RuntimeCore's own reading of the same flag, shared by every
385+
// persistent-plugin host.
386+
return !src.includes("cfg.retainSessions") && !src.includes("writeBackEnabled");
387+
});
388+
expect(ungated).toEqual([]);
389+
});
390+
});

0 commit comments

Comments
 (0)