Skip to content

Commit 4fa110d

Browse files
authored
coding-agents: keep the installed runtime current by itself (#3965)
`install` stages this package into ~/.hindsight/coding-agents and points every wired agent's hooks at that copy. Nothing ever refreshed it: the only update path was the user remembering to re-run `install`, so a machine could sit several versions behind indefinitely — a fix only reached people who happened to re-install. Found on a machine running 0.4.2 while 0.4.3 had been published for days, with no signal anywhere that an update existed. Once a day, at session start, ask the registry for the published version and — when it is newer — spawn a detached updater. The current session keeps running the code it already loaded; the next one starts on the new build. `update` is a new installer command: `install`'s staging half and nothing else. It replaces the staged runtime (a path stable across versions, so every wired agent picks the new code up on its next spawn) and writes to NO host config. That separation is what makes it safe to run unattended — an `install` would need a harness list, and choosing one on the user's behalf would rewire agents they never asked us to touch. The cost is bounded and documented: a release introducing a NEW hook entry point is staged but not referenced until a manual `install`. Only ever replaces a runtime it can prove npx downloaded. `stageRuntime` records the directory it copied from, and a copy staged from `npm i -g`, from a project dependency, or from a local checkout is left to whoever manages that source: re-staging behind npm's back would leave `npm ls -g` naming a version that is no longer what runs, and re-staging over a checkout would replace a developer's own build mid-session. A missing marker means no — it is written on every install from this version on, and a machine has to re-install once to get this code at all, so a runtime old enough to lack it is too old to be running the check. Failing closed costs one manual install; failing open costs somebody their working tree. Concurrency is real here, not hypothetical: this is a plugin for machines that run five agents at once, and a 24h stamp does not serialise anything — several sessions starting in the same second all read "due" before any has written it. Two concurrent stageRuntime runs are `rmSync(dist)` then `cpSync`, where one process deletes the directory the other is half way through writing, leaving a runtime with missing entry points and every hook broken. A lock claimed before the registry call makes a burst produce ONE request and one updater; same shape as deepen.ts's per-bank lock, with the holder's pid deciding liveness so a crash cannot wedge the window, and the stored pid is the detached CHILD's since the copy outlives the session. Other guards, each with a test: `npx` must be on PATH (without it there is nothing to spawn, so the check is skipped rather than burning a request and failing a spawn asynchronously); a prerelease never supersedes the release of the same version; an unreadable staged version never guesses; the survey's own headless session is excluded; and both ownership refusals stamp the check so each states its reason at most once a day. `autoUpdate: false` (or HINDSIGHT_AUTO_UPDATE=false) pins the installed version, settable globally, per harness or per bank. `disabled` stops it too — an inert plugin should stay inert, and a network call plus a background npm install is not inert. Wired at BOTH session-start paths — `runSessionStartHook` for the hook harnesses and `RuntimeCore.seedIfCold` for the persistent-plugin hosts — plus a family-wide guard test that enumerates session starts structurally rather than from a hand-maintained list, so a third host cannot land without an update check. Session-start housekeeping has gone missing on the plugin hosts before (#3524), and the harness that forgets is by definition the one whose test nobody wrote. Known window, documented in the module doc: staging replaces dist/ wholesale, so a hook spawning during the copy can fail to load. Running processes are unaffected, the window is milliseconds once a day, and the cost is one turn without memory. Serialising against it would need a lock every hook takes on every turn — a worse trade than the window it closes. Also hoists survey.ts's `binExists` to util.ts as `binOnPath`. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk
1 parent c959122 commit 4fa110d

15 files changed

Lines changed: 1114 additions & 162 deletions

File tree

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

Lines changed: 42 additions & 34 deletions
Large diffs are not rendered by default.

hindsight-integrations/coding-agents/README.md

Lines changed: 42 additions & 34 deletions
Large diffs are not rendered by default.

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

Lines changed: 42 additions & 34 deletions
Large diffs are not rendered by default.

hindsight-integrations/coding-agents/src/core/auto-update.test.ts

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.

hindsight-integrations/coding-agents/src/core/auto-update.ts

Lines changed: 365 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ export interface RawConfig {
124124
/** Plugin log verbosity ("debug" | "info" | "warn" | "error", default "info");
125125
* HINDSIGHT_LOG_LEVEL overrides for ad-hoc debugging. */
126126
logLevel?: "debug" | "info" | "warn" | "error";
127+
/** Keep the installed runtime current by itself (default true). Once a day a session start asks
128+
* npm for the published version and, when it is newer, re-stages ~/.hindsight/coding-agents in
129+
* the background — the copy every wired agent's hooks already point at. It rewires no host
130+
* config, so a release that adds a NEW hook entry point still needs a manual `install`.
131+
* Set false to pin the installed version (air-gapped machines, or a deliberate downgrade);
132+
* updating is then `npx @vectorize-io/hindsight-coding-agents install` again, as before. */
133+
autoUpdate?: boolean;
127134
surveyRefreshCommits?: number; // re-run the survey at SessionStart once this many commits have accrued since the last one, so structural pages track an evolving architecture (default 20; 0 = cold-seed only)
128135
/** How git history feeds memory — seeding AND keeping current use the same engine:
129136
* "message" = commit messages only (cheap aggregated doc, re-upserted when HEAD moves);
@@ -201,6 +208,7 @@ export interface Config {
201208
observationScopes: ObservationScopes;
202209
banks: Record<string, Omit<RawConfig, "banks" | "harnesses"> & { bank?: string }>;
203210
logLevel: "debug" | "info" | "warn" | "error";
211+
autoUpdate: boolean;
204212
}
205213

206214
/**
@@ -344,6 +352,7 @@ export function resolveConfig(raw: RawConfig = {}): Config {
344352
logLevel: ["debug", "info", "warn", "error"].includes(raw.logLevel as string)
345353
? (raw.logLevel as "debug" | "info" | "warn" | "error")
346354
: "info",
355+
autoUpdate: raw.autoUpdate ?? true,
347356
};
348357
}
349358

@@ -433,6 +442,7 @@ const ENV_KEYS = {
433442
surveyBudgetUsd: "HINDSIGHT_SURVEY_BUDGET_USD",
434443
surveyRefreshCommits: "HINDSIGHT_SURVEY_REFRESH_COMMITS",
435444
logLevel: "HINDSIGHT_LOG_LEVEL",
445+
autoUpdate: "HINDSIGHT_AUTO_UPDATE",
436446
gitIngest: "HINDSIGHT_GIT_INGEST",
437447
// Scalar modes only ("shared", "combined", "per_tag", "all_combinations"). An explicit scope
438448
// list is a list OF lists, which does not survive flattening into one variable — file-only.
@@ -452,6 +462,7 @@ const ENV_BOOLEANS = new Set<keyof RawConfig>([
452462
"autoReflect",
453463
"autoSeed",
454464
"codebaseSurvey",
465+
"autoUpdate",
455466
]);
456467
const ENV_LISTS = new Set<keyof RawConfig>(["retainTags", "optInPaths"]);
457468
const ENV_NUMBERS = new Set<keyof RawConfig>([

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* No opencode/claude specifics live here — only the memory logic.
1515
*/
1616
import type { Config } from "./config";
17+
import { maybeAutoUpdate } from "./auto-update";
1718
import { DAEMON_WAIT_RETAIN_MS, DAEMON_WAIT_SESSION_START_MS, ensureDaemon } from "./daemon";
1819
import { diag } from "./diag";
1920
import { describeError, log, setLogLevel } from "./log";
@@ -112,6 +113,10 @@ export class RuntimeCore {
112113
// `runSessionStartHook` is a hook-only wrapper, so calling `buildSessionStartContext` directly
113114
// (as every plugin harness does) skipped the ensure entirely.
114115
await ensureDaemon(this.cfg, this.harness, { waitMs: DAEMON_WAIT_SESSION_START_MS });
116+
// Same session-start housekeeping the hook harnesses do in `runSessionStartHook`: a persistent
117+
// plugin host is a session start too, and leaving it out would mean opencode/Kilo/Cline users
118+
// never got an update — the parity gap `ensureDaemon` above already had to be fixed for.
119+
void maybeAutoUpdate(this.cfg);
115120
try {
116121
const out = await buildSessionStartContext({
117122
cwd: repoPath || process.cwd(),

hindsight-integrations/coding-agents/src/core/session-start.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { readFileSync } from "node:fs";
2222
import { gitHeadSha, hasGitHistory, commitsSince, repoNameOf } from "./git";
2323
import { DEEPEN_DIFF_TARGET } from "./status";
2424
import { startBackgroundSeed } from "./seed";
25+
import { maybeAutoUpdate } from "./auto-update";
2526
import { syncCompanionSkill } from "./skill-sync";
2627
import { SURVEY_DOC_IDS, startCodebaseSurvey, type SurveyHarness } from "./survey";
2728
import { applyBankConfig, loadConfig } from "./config";
@@ -352,6 +353,12 @@ export async function runSessionStartHook(
352353
setLogLevel(cfg.logLevel);
353354
syncCompanionSkill(harness); // keep the installed skill current with the package version
354355
if (cfg.disabled) return;
356+
// …and keep the package itself current. AFTER the disabled check, unlike the skill sync above:
357+
// `disabled` means an inert plugin, and a network call plus a background npm install is not
358+
// inert. It also keeps the two harness families symmetric — the plugin hosts never construct a
359+
// RuntimeCore when disabled (harness/plugin-entry.ts), so they already skip this.
360+
// Detached and rate-limited to once a day; the update lands for the NEXT session.
361+
void maybeAutoUpdate(cfg);
355362

356363
// Recorded HERE, on the session's first hook, so every later hook of this session resolves the
357364
// same bank however far the agent navigates (#3563).

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

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@
3737
* missing binary or a spawn failure must silently no-op, never crash the caller.
3838
*/
3939
import { spawn as realSpawn } from "node:child_process";
40-
import { accessSync, constants, existsSync } from "node:fs";
40+
import { existsSync } from "node:fs";
4141
import { homedir } from "node:os";
42-
import { delimiter, dirname, join } from "node:path";
42+
import { dirname, join } from "node:path";
4343
import { fileURLToPath } from "node:url";
44+
import { binOnPath } from "./util";
4445

4546
/** Deterministic doc ids of the survey's findings (its fixed titles slugified by
4647
* hindsight_ingest_document). Their presence in the bank = the survey actually FINISHED —
@@ -117,28 +118,6 @@ function resolveAgentBin(harness: SurveyHarness, claudeBin?: string): string {
117118
}
118119
}
119120

120-
/** Is `bin` runnable? A path (contains "/") -> exists + executable; a bare name -> found on PATH. */
121-
function binExists(bin: string): boolean {
122-
try {
123-
if (bin.includes("/")) {
124-
accessSync(bin, constants.X_OK);
125-
return true;
126-
}
127-
for (const dir of (process.env.PATH || "").split(delimiter)) {
128-
if (!dir) continue;
129-
try {
130-
accessSync(join(dir, bin), constants.X_OK);
131-
return true;
132-
} catch {
133-
/* keep scanning PATH */
134-
}
135-
}
136-
return false;
137-
} catch {
138-
return false;
139-
}
140-
}
141-
142121
export const SURVEY_PROMPT =
143122
"You are performing a one-time structural survey of THIS repository to seed its Hindsight " +
144123
"memory. Work efficiently — DO NOT read every file; sample enough to understand the " +
@@ -318,7 +297,7 @@ export function startCodebaseSurvey(
318297
): void {
319298
try {
320299
const spawnFn = opts.spawn ?? realSpawn;
321-
const exists = opts.exists ?? binExists;
300+
const exists = opts.exists ?? binOnPath;
322301
const mcpServerPath =
323302
opts.mcpServerPath ?? join(dirname(fileURLToPath(import.meta.url)), "mcp-server.js");
324303

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11
/** Small shared helpers (no harness or Hindsight coupling). */
2+
import { accessSync, constants } from "node:fs";
3+
import { delimiter, join } from "node:path";
4+
5+
/**
6+
* Is `bin` runnable? A path (contains "/") -> exists + executable; a bare name -> found on PATH.
7+
*
8+
* Resolved by hand rather than by spawning: the callers use this to DECIDE whether to spawn, and
9+
* `which`/`where` is itself a process launch on a path where the answer is usually "no".
10+
*/
11+
export function binOnPath(bin: string): boolean {
12+
try {
13+
if (bin.includes("/")) {
14+
accessSync(bin, constants.X_OK);
15+
return true;
16+
}
17+
for (const dir of (process.env.PATH || "").split(delimiter)) {
18+
if (!dir) continue;
19+
try {
20+
accessSync(join(dir, bin), constants.X_OK);
21+
return true;
22+
} catch {
23+
/* keep scanning PATH */
24+
}
25+
}
26+
return false;
27+
} catch {
28+
return false;
29+
}
30+
}
231

332
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
433

0 commit comments

Comments
 (0)