mirror-mind 0.32.1 → 0.33.1

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.
Files changed (35) hide show
  1. package/.claude/hooks/log-assistant-reply.sh +85 -0
  2. package/.pi/extensions/mirror-logger.ts +107 -22
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.33.0.md +164 -0
  5. package/docs/releases/v0.33.1.md +174 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +6 -2
  8. package/plugins/mirror-mind/hooks/hooks.json +18 -6
  9. package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +4 -2
  11. package/ts/src/conversation/hookPayload.ts +51 -0
  12. package/ts/src/conversation/logger.ts +300 -25
  13. package/ts/src/conversation/loggerCli.ts +41 -2
  14. package/ts/src/frontDoor/cli.ts +8 -1
  15. package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -10
  16. package/ts/src/frontDoor/routing.ts +2 -0
  17. package/ts/src/frontDoor/runtimeRoute.ts +142 -24
  18. package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
  19. package/ts/src/frontDoor/stdinSource.ts +37 -0
  20. package/ts/src/guards/claudePlugin.ts +56 -10
  21. package/ts/src/guards/packContents.ts +1 -1
  22. package/ts/src/hooks/claude.ts +52 -14
  23. package/ts/src/hooks/payload.ts +18 -13
  24. package/ts/src/hooks/runtime.ts +24 -4
  25. package/ts/src/hooks/runtimeDetection.ts +34 -0
  26. package/ts/src/mirror/mirrorForm.ts +43 -0
  27. package/ts/src/runtime/git.ts +8 -2
  28. package/ts/src/runtime/releaseNotes.ts +230 -52
  29. package/ts/src/runtime/strategies/clone.ts +10 -3
  30. package/ts/src/runtime/strategies/package.ts +112 -23
  31. package/ts/src/util/tarEntries.ts +128 -0
  32. package/ts/src/welcome/card.ts +182 -60
  33. package/ts/src/welcome/statusLine.ts +12 -2
  34. package/ts/src/welcome/updateCache.ts +45 -27
  35. package/plugins/mirror-mind/mcp/launch.sh +0 -52
@@ -66,14 +66,64 @@ export function readVersion(repoRoot: string): string {
66
66
  return version;
67
67
  }
68
68
 
69
+ /**
70
+ * The directories the MCP entry and the plugin's hook wrappers search for
71
+ * `mirror` and `node` when the PATH does not hold them: a GUI launch leaves
72
+ * the npm global bin off it. One list, so `mcpEntry.test.ts` can hold the
73
+ * entry's search equal to the wrappers' (`generate_hook_wrappers.sh`).
74
+ */
75
+ export const GLOBAL_BIN_DIRS = ['"$HOME/.nvm/current/bin"', "/opt/homebrew/bin", "/usr/local/bin"];
76
+
77
+ /**
78
+ * The MCP entry's locator (CV21.DS2.US6).
79
+ *
80
+ * The manifest used to name `${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh`. Claude Code
81
+ * expands that variable; Codex does not expand it in an MCP command (DS12.TS2
82
+ * L6), so the server never started there. The locator is the launcher's
83
+ * search, inline: `MIRROR_BIN`, the PATH, then the global bin directories for
84
+ * `mirror`; `MIRROR_NODE`, the PATH, the directory `mirror` was found in, then
85
+ * the same directories for `node`. A client whose PATH holds neither (a GUI
86
+ * launch) still starts the server, and one with no `mirror` anywhere gets one
87
+ * stderr line naming the install step, and exit 1.
88
+ *
89
+ * Written for what three runtimes do to the string (the US6 spike):
90
+ *
91
+ * * POSIX `sh`: `/bin/sh` is dash on Linux;
92
+ * * no `${...}`: Claude Code expands that form in MCP configuration, and a
93
+ * runtime that did so with an unset name would blank it;
94
+ * * no positional parameter (`$1`, `$@`, ...) and no backtick, for the same
95
+ * reason, and because the text travels through two JSON encoders;
96
+ * * variables named `mmbin`, `mmnode`, `mmdir`: Grok substitutes SET
97
+ * environment variables by name inside MCP args at load time (`$HOME`
98
+ * became the path), so a script variable with a name a person exports
99
+ * would be replaced before the shell ever saw it.
100
+ */
101
+ export const MCP_LOCATOR = [
102
+ 'mmbin=""',
103
+ '[ -n "$MIRROR_BIN" ] && [ -x "$MIRROR_BIN/mirror" ] && mmbin="$MIRROR_BIN/mirror"',
104
+ '[ -n "$mmbin" ] || mmbin="$(command -v mirror 2>/dev/null)"',
105
+ `for mmdir in ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmbin" ] && break; [ -x "$mmdir/mirror" ] && mmbin="$mmdir/mirror"; done`,
106
+ '[ -n "$mmbin" ] || { echo "mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory." >&2; exit 1; }',
107
+ 'mmnode="$MIRROR_NODE"',
108
+ '[ -n "$mmnode" ] && [ -x "$mmnode" ] || mmnode="$(command -v node 2>/dev/null)"',
109
+ `for mmdir in "$(dirname "$mmbin")" ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmnode" ] && break; [ -x "$mmdir/node" ] && mmnode="$mmdir/node"; done`,
110
+ '[ -n "$mmnode" ] || { echo "mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE." >&2; exit 1; }',
111
+ 'exec "$mmnode" "$mmbin" mcp',
112
+ ].join("; ");
113
+
114
+ /** The `mcpServers` entry: the locator, run by the system shell. */
115
+ export const MCP_ENTRY: { readonly command: string; readonly args: readonly string[] } = {
116
+ command: "/bin/sh",
117
+ args: ["-c", MCP_LOCATOR],
118
+ };
119
+
69
120
  /**
70
121
  * The Claude plugin manifest (minimal; no unsupported keys).
71
122
  *
72
- * Declares the Mirror MCP server so the canonical package carries it. Since
73
- * CV22.DS9.TS2 the entry is the plugin's own launcher rather than an engine,
74
- * so reverting never requires editing a plugin installed inside someone's
75
- * runtime (DS9 decision D5). TS5 removes that launcher's Python branch; US3
76
- * repoints this at the npm entry point.
123
+ * Declares the Mirror MCP server so the canonical package carries it. The
124
+ * entry names no file of the plugin's and no variable a runtime must expand
125
+ * (`MCP_LOCATOR`): Claude Code, Codex, and Grok render it as written, and
126
+ * each starts the server from it (CV21.DS2.US6).
77
127
  */
78
128
  export function buildManifest(version: string): Record<string, unknown> {
79
129
  return {
@@ -82,11 +132,7 @@ export function buildManifest(version: string): Record<string, unknown> {
82
132
  description: PLUGIN_DESCRIPTION,
83
133
  author: { name: PLUGIN_AUTHOR },
84
134
  mcpServers: {
85
- // The placeholder is Claude Code's, expanded when it loads the plugin. A
86
- // JavaScript template would resolve at BUILD time, against this
87
- // repository, shipping an absolute path from the release machine.
88
- // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude expands this at load time, not JS
89
- [PLUGIN_NAME]: { command: "${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh" },
135
+ [PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
90
136
  },
91
137
  };
92
138
  }
@@ -56,7 +56,7 @@ export const REQUIRED_FILES: readonly string[] = [
56
56
  ".pi/skills/mm-mirror/SKILL.md",
57
57
  ".claude/hooks/session-start.sh",
58
58
  ".gemini/hooks/session-start.sh",
59
- "plugins/mirror-mind/mcp/launch.sh",
59
+ "plugins/mirror-mind/hooks/hooks.json",
60
60
  "scripts/codex-hooks/session-start.sh",
61
61
  "scripts/codex-mirror.sh",
62
62
  ];
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { resolveDbPath } from "#frontDoor/dbPath.ts";
15
15
  import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
16
+ import { mirrorForm } from "#mirror/mirrorForm.ts";
16
17
  import { markInjected, needsInject, readMirrorState } from "./mirrorState.ts";
17
18
  import { parseHookPayload, readStdin } from "./payload.ts";
18
19
  import {
@@ -22,6 +23,7 @@ import {
22
23
  runFrontDoorQuietly,
23
24
  sessionStartOutput,
24
25
  } from "./runtime.ts";
26
+ import { detectHookRuntime } from "./runtimeDetection.ts";
25
27
 
26
28
  /**
27
29
  * `session-start.sh`: make sure logging is on for this session, and hand
@@ -39,17 +41,49 @@ export async function claudeSessionStart(env: NodeJS.ProcessEnv = process.env):
39
41
  }
40
42
 
41
43
  /**
42
- * `log-user-prompt.sh`: record the user's turn.
44
+ * `log-user-prompt.sh`: record the user's turn, under the runtime's own label.
43
45
  *
44
- * The shell piped stdin into `hook_user_prompt()`. The front door already owns
45
- * that surface -- `conversation-logger user-prompt` is one of its two
46
- * stdin-reading subcommands -- so the payload is handed straight to it rather
47
- * than re-parsed here.
46
+ * The shell piped stdin into `hook_user_prompt()`, and the front door owns
47
+ * that surface: `conversation-logger user-prompt` reads the payload itself.
48
+ * Since CV21.DS2.US8 the entry reads it first, to tell which runtime it is in
49
+ * (`runtimeDetection.ts`), and hands it to the route through the one-shot
50
+ * stdin seam, so fd 0 is read once.
48
51
  */
49
- export async function claudeUserPrompt(): Promise<number> {
50
- // stdin is deliberately NOT consumed here: the route reads it itself, so
51
- // handing it the payload means not reading it first.
52
- await runFrontDoorQuietly("claude:user-prompt", ["conversation-logger", "user-prompt"]);
52
+ export async function claudeUserPrompt(stdin: string): Promise<number> {
53
+ const runtime = detectHookRuntime(parseHookPayload(stdin).raw);
54
+ await runFrontDoorQuietly(
55
+ "claude:user-prompt",
56
+ ["conversation-logger", "user-prompt", "--interface", runtime],
57
+ { stdin },
58
+ );
59
+ return 0;
60
+ }
61
+
62
+ /**
63
+ * `log-assistant-reply.sh`, on Stop: record the turn's final answer beside
64
+ * its prompt (CV21.DS2.US8). Every Claude-schema runtime hands its Stop hook
65
+ * `last_assistant_message` (DS12.TS2 L11, TS4 L8; Claude Code 2.1.296's
66
+ * binary), so the reply needs no transcript.
67
+ *
68
+ * This hook prints NOTHING and exits 0, whatever happens: a Stop hook that
69
+ * printed JSON or exited 2 would keep the agent working instead of logging.
70
+ * The route's outcome line is captured in-process and never forwarded. Only a
71
+ * reply lost for no known reason -- its turn's prompt neither logged nor
72
+ * skipped as a command, the race the plan names -- is recorded in `hooks.log`,
73
+ * the fact and never the text: `runtime diagnose` reads that file as failures,
74
+ * and a command turn's reply or a session-scoped Stop is not one.
75
+ */
76
+ export async function claudeStop(stdin: string): Promise<number> {
77
+ const runtime = detectHookRuntime(parseHookPayload(stdin).raw);
78
+ const outcome = await runFrontDoorQuietly(
79
+ "claude:stop",
80
+ ["conversation-logger", "stop", "--interface", runtime],
81
+ { stdin },
82
+ );
83
+ const line = outcome.trim();
84
+ if (line.startsWith("reply skipped: nothing_waiting") || line.startsWith("reply failed")) {
85
+ noteHookFailure("claude:stop", line);
86
+ }
53
87
  return 0;
54
88
  }
55
89
 
@@ -65,7 +99,9 @@ export async function claudeSessionEnd(): Promise<number> {
65
99
  *
66
100
  * Two cases, exactly as the shell had them:
67
101
  *
68
- * 1. an explicit `/mm:mirror [query]` -- load context for that query;
102
+ * 1. an explicit Mirror form -- `/mm:mirror [query]`, or the same skill in
103
+ * any runtime's spelling (`#mirror/mirrorForm.ts`; CV21.DS2.US7) -- load context
104
+ * for that query;
69
105
  * 2. Mirror Mode already active for this session with context not yet
70
106
  * injected -- load it with the session's persona and journey, then mark
71
107
  * it injected so it happens once.
@@ -79,9 +115,9 @@ export async function claudeInject(stdin: string): Promise<number> {
79
115
  const sessionId = payload.sessionId;
80
116
 
81
117
  // Case 1: the explicit invocation.
82
- if (/^\/mm:mirror/.test(payload.prompt)) {
83
- const query = payload.prompt.replace(/^\/mm:mirror\s*/, "");
84
- const argv = ["mirror", "load", "--context-only", "--query", query];
118
+ const explicit = mirrorForm(payload.prompt);
119
+ if (explicit) {
120
+ const argv = ["mirror", "load", "--context-only", "--query", explicit.query];
85
121
  if (sessionId) argv.push("--session-id", sessionId);
86
122
  const result = await runFrontDoor(argv);
87
123
  if (result.stdout.trim()) process.stdout.write(result.stdout);
@@ -164,7 +200,9 @@ export async function claudeHook(name: string): Promise<number> {
164
200
  case "session-start":
165
201
  return claudeSessionStart();
166
202
  case "user-prompt":
167
- return claudeUserPrompt();
203
+ return claudeUserPrompt(readStdin());
204
+ case "stop":
205
+ return claudeStop(readStdin());
168
206
  case "session-end":
169
207
  return claudeSessionEnd();
170
208
  case "inject":
@@ -11,16 +11,22 @@
11
11
  // user's turn. That posture is preserved exactly.
12
12
 
13
13
  import { readFileSync } from "node:fs";
14
+ import {
15
+ type HookPayloadObject,
16
+ parseHookPayloadObject,
17
+ promptOf,
18
+ sessionIdOf,
19
+ } from "#conversation/hookPayload.ts";
14
20
 
15
21
  export interface HookPayload {
16
22
  /** The user's prompt text, or "" when the payload has none. */
17
23
  readonly prompt: string;
18
- /** The runtime's session id, or "" when the payload has none. */
24
+ /** The runtime's session id (`session_id`, or Grok's `sessionId`), or "". */
19
25
  readonly sessionId: string;
20
26
  /** The assistant's response text (Gemini's `prompt_response`), or "". */
21
27
  readonly response: string;
22
28
  /** Everything else, for hooks that need a field these three do not cover. */
23
- readonly raw: Record<string, unknown>;
29
+ readonly raw: HookPayloadObject;
24
30
  }
25
31
 
26
32
  const EMPTY: HookPayload = { prompt: "", sessionId: "", response: "", raw: {} };
@@ -29,19 +35,18 @@ function text(value: unknown): string {
29
35
  return typeof value === "string" ? value : "";
30
36
  }
31
37
 
32
- /** Parse a hook payload. Never throws: an unparseable payload is empty. */
38
+ /**
39
+ * Parse a hook payload. Never throws: an unparseable payload is empty.
40
+ *
41
+ * The field spellings live in `#conversation/hookPayload.ts`, one reader for
42
+ * the entries and the logger (CV21.DS2.US8).
43
+ */
33
44
  export function parseHookPayload(input: string): HookPayload {
34
- let parsed: unknown;
35
- try {
36
- parsed = JSON.parse(input);
37
- } catch {
38
- return EMPTY;
39
- }
40
- if (typeof parsed !== "object" || parsed === null) return EMPTY;
41
- const raw = parsed as Record<string, unknown>;
45
+ const raw = parseHookPayloadObject(input);
46
+ if (raw === null) return EMPTY;
42
47
  return {
43
- prompt: text(raw.prompt),
44
- sessionId: text(raw.session_id),
48
+ prompt: promptOf(raw),
49
+ sessionId: sessionIdOf(raw),
45
50
  // Gemini's AfterAgent payload names it `prompt_response`, and that is the
46
51
  // field the Python hook read. The first port read three guessed names
47
52
  // instead and dropped every Gemini assistant turn in silence until
@@ -21,6 +21,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } fro
21
21
  import { dirname, join } from "node:path";
22
22
 
23
23
  import { resolveMirrorHome } from "#frontDoor/dbPath.ts";
24
+ import { provideStdin } from "#frontDoor/stdinSource.ts";
24
25
  import { runningTreeRoot } from "#runtime/treeRoot.ts";
25
26
 
26
27
  /** Where a hook records that it could not do its job. */
@@ -106,6 +107,14 @@ export interface FrontDoorRun {
106
107
  readonly stdout: string;
107
108
  }
108
109
 
110
+ export interface FrontDoorRunOptions {
111
+ /**
112
+ * A payload the entry already read from fd 0, for a route that reads its
113
+ * payload from stdin (CV21.DS2.US8). The route takes it instead of fd 0.
114
+ */
115
+ readonly stdin?: string;
116
+ }
117
+
109
118
  /**
110
119
  * Run a front-door command in this process, capturing what it printed.
111
120
  *
@@ -113,8 +122,12 @@ export interface FrontDoorRun {
113
122
  * produced -- the shell version tested `[ -n "$CONTEXT" ]` on command
114
123
  * substitution, and this is the same test without a second process.
115
124
  */
116
- export async function runFrontDoor(argv: readonly string[]): Promise<FrontDoorRun> {
125
+ export async function runFrontDoor(
126
+ argv: readonly string[],
127
+ options: FrontDoorRunOptions = {},
128
+ ): Promise<FrontDoorRun> {
117
129
  const { main } = await import("#frontDoor/cli.ts");
130
+ if (options.stdin !== undefined) provideStdin(options.stdin);
118
131
  const chunks: string[] = [];
119
132
  const original = process.stdout.write.bind(process.stdout);
120
133
  // biome-ignore lint/suspicious/noExplicitAny: matching Node's overloaded write signature
@@ -145,15 +158,22 @@ function commandName(argv: readonly string[]): string {
145
158
 
146
159
  /**
147
160
  * Run a front-door command for its effect, swallowing failure the way the
148
- * shell hooks did with `|| true` -- but recording it.
161
+ * shell hooks did with `|| true` -- but recording it. Returns what the route
162
+ * printed, for an entry that reads an outcome line from it.
149
163
  */
150
- export async function runFrontDoorQuietly(hook: string, argv: readonly string[]): Promise<void> {
164
+ export async function runFrontDoorQuietly(
165
+ hook: string,
166
+ argv: readonly string[],
167
+ options: FrontDoorRunOptions = {},
168
+ ): Promise<string> {
151
169
  try {
152
- const result = await runFrontDoor(argv);
170
+ const result = await runFrontDoor(argv, options);
153
171
  if (result.code !== 0) {
154
172
  noteHookFailure(hook, `\`${commandName(argv)}\` exited ${result.code}`);
155
173
  }
174
+ return result.stdout;
156
175
  } catch (error) {
157
176
  noteHookFailure(hook, error instanceof Error ? error.message : String(error));
177
+ return "";
158
178
  }
159
179
  }
@@ -0,0 +1,34 @@
1
+ // Which runtime a Claude-schema hook is running in (CV21.DS2.US8).
2
+ //
3
+ // Three runtimes run the plugin's hooks as they are: Claude Code, Codex, and
4
+ // Grok. The hooks are Mirror's `claude:*` family, and until this story every
5
+ // session they logged was labelled `claude_code` (DS12.TS2 L7, TS4 L7). Each
6
+ // runtime leaves a mark the others do not:
7
+ //
8
+ // * Grok's runner sets `GROK_HOOK_EVENT` and `GROK_SESSION_ID` on every hook
9
+ // process (Grok's bundled guide, "Runner-injected variables"), and its
10
+ // payload names the event in camelCase, `hookEventName` (TS4 L8);
11
+ // * Codex's payload carries `turn_id` (TS2 L11), which Claude Code 2.1.296's
12
+ // common hook input does not: it carries `prompt_id`, read from the binary;
13
+ // * Claude Code is what remains.
14
+ //
15
+ // Grok is tested first because it aliases some fields into snake_case and may
16
+ // alias more; its own name for the event is the mark that cannot be a
17
+ // coincidence. A label is attribution, never authority: nothing privileged
18
+ // keys off it, so a payload that claimed another runtime's mark would mislabel
19
+ // its own session and nothing else.
20
+
21
+ import type { HookPayloadObject } from "#conversation/hookPayload.ts";
22
+
23
+ export type HookRuntime = "claude_code" | "codex" | "grok_build";
24
+
25
+ export function detectHookRuntime(
26
+ payload: HookPayloadObject,
27
+ env: NodeJS.ProcessEnv = process.env,
28
+ ): HookRuntime {
29
+ if (env.GROK_HOOK_EVENT || env.GROK_SESSION_ID || typeof payload.hookEventName === "string") {
30
+ return "grok_build";
31
+ }
32
+ if (typeof payload.turn_id === "string" && payload.turn_id !== "") return "codex";
33
+ return "claude_code";
34
+ }
@@ -0,0 +1,43 @@
1
+ // The explicit Mirror Mode form, in every runtime's skill syntax (CV21.DS2.US7).
2
+ //
3
+ // Lives with the mirror core rather than with the hooks because two readers
4
+ // share it: the inject hook (context for the question) and the prompt logger
5
+ // (the question as the conversation turn, US8).
6
+ //
7
+ // A person invokes the Mirror skill by name, and each runtime spells that
8
+ // name its own way before handing the prompt to the hooks (DS12):
9
+ //
10
+ // /mm:mirror … standalone Claude Code
11
+ // /mirror-mind:mm-mirror … the Claude Code plugin (TS1 L4)
12
+ // /mirror-mind:mm:mirror … Claude Code's menu form (TS1 static 6)
13
+ // $mirror-mind:mm:mirror … the Codex plugin (TS2 L10)
14
+ // /mm-mirror … Grok (TS4 L5)
15
+ // $mm-mirror … Codex's own skill form
16
+ //
17
+ // Three choices, each made by the runtime, not the person: the sigil (`/` or
18
+ // `$`), whether the plugin's name prefixes the skill, and whether the skill's
19
+ // `name: "mm:mirror"` or its directory `mm-mirror` is used. The grammar is
20
+ // their product, so a runtime that combines them differently is covered too.
21
+ //
22
+ // The form must open the prompt and end at whitespace or the end of it: a
23
+ // longer skill name (`/mm-mirrors`) is another skill, and a form quoted in the
24
+ // middle of a sentence is a mention. The inject hook loads context for the
25
+ // query that follows the form, and the prompt logger records that query as
26
+ // the conversation turn (US8), so both read the same grammar.
27
+
28
+ export interface MirrorForm {
29
+ /** The form as the runtime sent it, e.g. `/mirror-mind:mm-mirror`. */
30
+ readonly form: string;
31
+ /** What follows the form, with the whitespace between them removed. */
32
+ readonly query: string;
33
+ }
34
+
35
+ const FORM = /^([/$])(mirror-mind:)?mm[:-]mirror(?=\s|$)/;
36
+
37
+ /** The explicit Mirror Mode form at the start of a prompt, or null. */
38
+ export function mirrorForm(prompt: string): MirrorForm | null {
39
+ const match = FORM.exec(prompt);
40
+ if (!match) return null;
41
+ const form = match[0];
42
+ return { form, query: prompt.slice(form.length).replace(/^\s+/, "").trimEnd() };
43
+ }
@@ -13,7 +13,9 @@
13
13
  // Timeouts follow the oracle's two budgets, and for the reason recorded there:
14
14
  // local inspections are near-instant, while a single shared 2 s budget once
15
15
  // made a network operation report failure after it had already succeeded
16
- // (the v0.30.1 release incident). Network reads get 120 s.
16
+ // (the v0.30.1 release incident). Network reads get 120 s, except where a
17
+ // caller holds a tighter budget of its own: the welcome card's update check
18
+ // runs inside the session start and passes what is left of its 5 s (CR135).
17
19
 
18
20
  import { spawnSync } from "node:child_process";
19
21
  import { existsSync, readFileSync, realpathSync } from "node:fs";
@@ -260,11 +262,15 @@ function availability(
260
262
  * Is the channel's remote branch ahead of HEAD? The remote query is the only
261
263
  * authority: remote-tracking refs are only as fresh as the last fetch, and
262
264
  * trusting them is how a clone two releases behind gets told it is ahead.
265
+ *
266
+ * `networkTimeoutMs` bounds that query; `runtime update --check` keeps the
267
+ * network budget, and the welcome card passes what is left of its own.
263
268
  */
264
269
  export function checkUpdateAvailability(
265
270
  start: string,
266
271
  channel: string | null,
267
272
  version: string,
273
+ networkTimeoutMs: number = GIT_NETWORK_TIMEOUT_MS,
268
274
  ): UpdateAvailability {
269
275
  const startPath = resolve(start);
270
276
  const git = inspectGit(startPath);
@@ -303,7 +309,7 @@ export function checkUpdateAvailability(
303
309
  const lsRemote = runGit(
304
310
  ["ls-remote", remote, `refs/heads/${branch}`],
305
311
  git.repository,
306
- GIT_NETWORK_TIMEOUT_MS,
312
+ networkTimeoutMs,
307
313
  );
308
314
  if (lsRemote.code !== 0 || !lsRemote.stdout) {
309
315
  return availability(version, updateChannel, {