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
@@ -21,6 +21,7 @@ import {
21
21
  discardCurrentConversation,
22
22
  endSession,
23
23
  handleSessionEndHook,
24
+ handleStopHook,
24
25
  handleUserPromptHook,
25
26
  isMuted,
26
27
  logAssistantMessage,
@@ -54,6 +55,7 @@ export const CONVERSATION_LOGGER_SUBCOMMANDS = [
54
55
  "log-user",
55
56
  "log-assistant",
56
57
  "user-prompt",
58
+ "stop",
57
59
  "discard-current",
58
60
  "switch",
59
61
  "session-end-pi",
@@ -75,7 +77,11 @@ export const LLM_TAIL_SUBCOMMANDS: ReadonlySet<string> = new Set([
75
77
  ]);
76
78
 
77
79
  /** Subcommands that read their JSON payload from stdin. */
78
- export const STDIN_SUBCOMMANDS: ReadonlySet<string> = new Set(["user-prompt", "session-end"]);
80
+ export const STDIN_SUBCOMMANDS: ReadonlySet<string> = new Set([
81
+ "user-prompt",
82
+ "stop",
83
+ "session-end",
84
+ ]);
79
85
 
80
86
  function extractOption(
81
87
  args: string[],
@@ -146,10 +152,43 @@ export async function runConversationLoggerCommand(
146
152
  return ok([isMuted(home) ? "MUTED" : "ACTIVE"]);
147
153
 
148
154
  case "user-prompt":
149
- handleUserPromptHook(db, context.stdin ?? "", { mirrorHome: home }, deps);
155
+ // `--interface` is the runtime the hook detected (US8); without it the
156
+ // label is Python's hook default.
157
+ handleUserPromptHook(
158
+ db,
159
+ context.stdin ?? "",
160
+ { mirrorHome: home, interface: interfaceOption(args, "claude_code") },
161
+ deps,
162
+ );
150
163
  // The hook is silent and always exits 0, whatever the outcome.
151
164
  return ok([]);
152
165
 
166
+ case "stop": {
167
+ // The Stop hook's payload (US8): the turn's final answer, logged beside
168
+ // its prompt when one is waiting. Exit 0 whatever the outcome, and the
169
+ // outcome on stdout in one line: the hook entry runs this route
170
+ // in-process, reads the line, and forwards nothing -- a Stop hook that
171
+ // printed would keep the agent working on some runtimes -- while a
172
+ // person running the command from a shell sees what happened.
173
+ const outcome = handleStopHook(
174
+ db,
175
+ context.stdin ?? "",
176
+ { mirrorHome: home, interface: interfaceOption(args, "claude_code") },
177
+ deps,
178
+ );
179
+ switch (outcome.action) {
180
+ case "logged":
181
+ return ok([`reply logged (${outcome.pairedBy})`]);
182
+ case "skipped":
183
+ return ok([`reply skipped: ${outcome.reason}`]);
184
+ case "muted":
185
+ return ok(["reply not logged: muted"]);
186
+ default:
187
+ // The error text is the fault's, never the payload's.
188
+ return ok([`reply failed: ${outcome.error}`]);
189
+ }
190
+ }
191
+
153
192
  case "session-end":
154
193
  await handleSessionEndHook(
155
194
  db,
@@ -164,6 +164,7 @@ import { retiredRefusal, routeMemoryCommand } from "./routing.ts";
164
164
  import { runRuntimeReadRoute, runWelcomeRoute } from "./runtimeRoute.ts";
165
165
  import { runMemorySearchRoute } from "./searchRoute.ts";
166
166
  import { resolveSeedPaths } from "./seedPaths.ts";
167
+ import { isSessionStartWelcome, runSessionStartWelcomeRoute } from "./sessionStartWelcomeRoute.ts";
167
168
  import { runSoulRoute } from "./soulRoute.ts";
168
169
  import {
169
170
  applyTasksImport,
@@ -1692,7 +1693,13 @@ async function dispatch(argv: readonly string[]): Promise<DispatchOutcome> {
1692
1693
 
1693
1694
  /** Every route the front door answers once routing has chosen it. */
1694
1695
  async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOutcome> {
1695
- if (isConversationLoggerCommand(argv)) return runConversationLoggerWrite(argv);
1696
+ if (isConversationLoggerCommand(argv)) {
1697
+ // CV9.E2.S32: the Pi extension's one-process session open. The write is
1698
+ // the same `withMirrorWriteDb`; the welcome renders follow it in-process.
1699
+ if (isSessionStartWelcome(argv))
1700
+ return runSessionStartWelcomeRoute(argv, { withMirrorWriteDb });
1701
+ return runConversationLoggerWrite(argv);
1702
+ }
1696
1703
  if (argv[0] === "build") {
1697
1704
  // The Builder command tree is deliberately lazy: the other commands never
1698
1705
  // pay for loading it, and a broken Builder core cannot take them down.
@@ -6,13 +6,13 @@
6
6
  // contract. (Until CV22.DS10.TS5 it could also return `null` to hand an LLM-tail
7
7
  // subcommand back to Python; that path left with the fallback.)
8
8
 
9
- import { readFileSync } from "node:fs";
10
9
  import { homedir } from "node:os";
11
10
  import { dirname } from "node:path";
12
11
  import { createZipBackup } from "#backup/zipBackup.ts";
13
12
  import { runConversationLoggerCommand, STDIN_SUBCOMMANDS } from "#conversation/loggerCli.ts";
14
13
  import { createLoggerRuntime } from "#conversation/loggerRuntime.ts";
15
14
  import type { WritableDatabase } from "#db/database.ts";
15
+ import { takeStdin } from "#frontDoor/stdinSource.ts";
16
16
  import { newId, nowIso } from "#util/pyGenerators.ts";
17
17
 
18
18
  export function isConversationLoggerCommand(argv: readonly string[]): boolean {
@@ -29,19 +29,27 @@ function subcommandOf(args: readonly string[]): string {
29
29
  return remaining[0] ?? "";
30
30
  }
31
31
 
32
- /** The hook subcommands read their JSON payload from stdin. */
33
- function readStdin(): string {
34
- try {
35
- return readFileSync(0, "utf-8");
36
- } catch {
37
- return "";
38
- }
32
+ /**
33
+ * The hook subcommands read their JSON payload from stdin -- or take the one
34
+ * a hook entry already read, to detect its runtime (US8, `stdinSource.ts`).
35
+ */
36
+ const readStdin = takeStdin;
37
+
38
+ /**
39
+ * Where the route's lines go. The default is the process's own streams; the
40
+ * `session-start --welcome` composite (CV9.E2.S32) captures them instead, so
41
+ * the logger's stdout can travel inside one JSON envelope.
42
+ */
43
+ export interface ConversationLoggerRouteIo {
44
+ stdout?: (line: string) => void;
45
+ stderr?: (line: string) => void;
39
46
  }
40
47
 
41
48
  export async function runConversationLoggerRoute(
42
49
  db: WritableDatabase,
43
50
  dbPath: string,
44
51
  argv: readonly string[],
52
+ io: ConversationLoggerRouteIo = {},
45
53
  ): Promise<number> {
46
54
  const args = argv.slice(1);
47
55
  const subcommand = subcommandOf(args);
@@ -67,7 +75,9 @@ export async function runConversationLoggerRoute(
67
75
  const result = await runConversationLoggerCommand(db, args, runtime, {
68
76
  stdin: STDIN_SUBCOMMANDS.has(subcommand) ? readStdin() : undefined,
69
77
  });
70
- for (const line of result.stdout) process.stdout.write(`${line}\n`);
71
- for (const line of result.stderr) process.stderr.write(`${line}\n`);
78
+ const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
79
+ const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
80
+ for (const line of result.stdout) out(line);
81
+ for (const line of result.stderr) err(line);
72
82
  return result.exitCode;
73
83
  }
@@ -370,6 +370,8 @@ const TS_CONVERSATION_LOGGER_SUBCOMMANDS = new Set([
370
370
  "log-user",
371
371
  "log-assistant",
372
372
  "user-prompt",
373
+ // CV21.DS2.US8: the Stop hook's reply, deterministic like user-prompt.
374
+ "stop",
373
375
  "discard-current",
374
376
  // US10 slice F, group 2 (need slice E): diagnose, dry-run repair, Codex import.
375
377
  "diagnose-journeys",
@@ -46,13 +46,18 @@ import { detectInstallKind, type InstallKind } from "#runtime/installKind.ts";
46
46
  import { MIGRATE_DECLINED_EXIT, renderMigrate, runMigrate } from "#runtime/migrate.ts";
47
47
  import {
48
48
  buildPendingReleaseNotes,
49
+ buildPendingReleaseNotesFromRegistry,
50
+ type ReleaseNotesBundle,
49
51
  readReleaseNote,
50
52
  renderReleaseNote,
51
53
  renderReleaseNotesBundle,
54
+ unknownReleaseNotes,
52
55
  } from "#runtime/releaseNotes.ts";
53
56
  import { buildRuntimeStatus, renderRuntimeStatus, statusVerdict } from "#runtime/status.ts";
54
57
  import {
55
58
  checkPackageUpdateAvailability,
59
+ defaultNpmRunner,
60
+ type NpmRunner,
56
61
  npmRootGlobal,
57
62
  renderPackageUpdateAvailability,
58
63
  } from "#runtime/strategies/package.ts";
@@ -61,8 +66,9 @@ import { frontDoorSpawner, postUpdateHints, runUpdate, type UpdateSpawn } from "
61
66
  import { statusAllowsUpdatePreflight } from "#runtime/updateGate.ts";
62
67
  import { renderUpdateResult, updateLogDetail } from "#runtime/updatePipeline.ts";
63
68
  import { expandHome } from "#util/paths.ts";
64
- import { composeWelcome, welcomeDisabled } from "#welcome/card.ts";
69
+ import { composeWelcome, type RemoteCheck, welcomeDisabled } from "#welcome/card.ts";
65
70
  import { composeStatusLine } from "#welcome/statusLine.ts";
71
+ import { isoNow, recordUpToDate } from "#welcome/updateCache.ts";
66
72
 
67
73
  /** The `runtime` subcommands this story ports. */
68
74
  export const TS_RUNTIME_READ_SUBCOMMANDS = new Set([
@@ -165,18 +171,39 @@ function writeErr(io: RuntimeRouteIo, text: string): void {
165
171
  (io.stderr ?? ((value: string) => process.stderr.write(value)))(text);
166
172
  }
167
173
 
168
- /** Port of `welcome.main`. Always exits 0, like the oracle. */
169
- export function runWelcomeRoute(argv: readonly string[], io: RuntimeRouteIo = {}): number {
174
+ /**
175
+ * The seams of the routes that answer for the running install, beyond the
176
+ * route's own: the install and its npm, for tests. The welcome (CR135) and
177
+ * `release-notes pending` (CR134) take them.
178
+ */
179
+ export interface InstallRouteIo extends RuntimeRouteIo {
180
+ install?: InstallKind;
181
+ npm?: NpmRunner;
182
+ /** The OS home whose config directory holds a package's channel file. */
183
+ osHome?: string;
184
+ }
185
+
186
+ export type WelcomeRouteIo = InstallRouteIo;
187
+
188
+ /**
189
+ * Port of `welcome.main`. Always exits 0, like the oracle.
190
+ *
191
+ * The card and the status line answer for the install that is running, never
192
+ * for the cwd (CR135): Pi renders both in the person's project.
193
+ */
194
+ export function runWelcomeRoute(argv: readonly string[], io: WelcomeRouteIo = {}): number {
170
195
  const env = io.env ?? process.env;
171
- const cwd = io.cwd ?? process.cwd();
172
196
  const args = argv.slice(1);
173
197
  if (welcomeDisabled(env)) return 0;
174
198
 
175
199
  const home = resolveWelcomeHome(optionValue(args, "--mirror-home"), env);
200
+ // The running tree's manifest: a file read, so the status line still spawns nothing.
201
+ const version = packageVersion(runningTreeRoot()) ?? "unknown";
176
202
 
177
203
  if (args.includes("--status-line")) {
178
204
  const line = composeStatusLine({
179
205
  mirrorHome: home,
206
+ installedVersion: version,
180
207
  sessionId: optionValue(args, "--session-id"),
181
208
  env,
182
209
  });
@@ -184,23 +211,37 @@ export function runWelcomeRoute(argv: readonly string[], io: RuntimeRouteIo = {}
184
211
  return 0;
185
212
  }
186
213
 
187
- const version = packageVersion(runningTreeRoot()) ?? "unknown";
188
- const card = composeWelcome({
214
+ const { card, check } = composeWelcome({
189
215
  mirrorHome: home,
190
- cwd,
191
- treeRoot: runningTreeRoot(),
216
+ install: io.install ?? detectThisInstall(),
192
217
  env,
193
218
  version,
194
- updateChannel: inspectUpdateChannel(cwd, null),
219
+ ...(io.npm === undefined ? {} : { npm: io.npm }),
220
+ ...(io.osHome === undefined ? {} : { osHome: io.osHome }),
195
221
  });
196
222
  if (card) writeOut(io, `${card}\n`);
223
+ if (home !== null && check !== null) logWelcomeCheck(home, env, check);
197
224
  return 0;
198
225
  }
199
226
 
227
+ /**
228
+ * One front-door line per remote check, so a slow session start can be
229
+ * explained later: what was asked, how it answered, how long it took. Never a
230
+ * path or a version.
231
+ */
232
+ function logWelcomeCheck(home: string, env: NodeJS.ProcessEnv, check: RemoteCheck): void {
233
+ logFrontDoor(frontDoorLogPath(join(home, dbNameForEnv(env.MEMORY_ENV || "production"))), {
234
+ command: "welcome",
235
+ route: "ts",
236
+ exitCode: 0,
237
+ detail: `update-check source=${check.source} verdict=${check.verdict} ms=${check.ms}`,
238
+ });
239
+ }
240
+
200
241
  /** Port of the read half of `cmd_runtime`. */
201
242
  export async function runRuntimeReadRoute(
202
243
  argv: readonly string[],
203
- io: RuntimeRouteIo = {},
244
+ io: InstallRouteIo = {},
204
245
  ): Promise<number> {
205
246
  const env = io.env ?? process.env;
206
247
  const cwd = io.cwd ?? process.cwd();
@@ -280,19 +321,14 @@ export async function runRuntimeReadRoute(
280
321
  if (subcommand === "release-notes") {
281
322
  const target = firstPositional(args.slice(1)) ?? "latest";
282
323
  if (target === "pending") {
283
- writeOut(
284
- io,
285
- renderReleaseNotesBundle(
286
- buildPendingReleaseNotes({
287
- // `--from` omitted falls back to the checkout's own version,
288
- // exactly as the oracle does before normalizing.
289
- currentVersion: optionValue(args, "--from") ?? version,
290
- ref: optionValue(args, "--ref") ?? "origin/stable",
291
- fetch: !args.includes("--no-fetch"),
292
- start: cwd,
293
- }),
294
- ),
295
- );
324
+ const pending = pendingReleaseNotes(args, io, env, version);
325
+ if ("usage" in pending) {
326
+ writeErr(io, pending.usage);
327
+ return 2;
328
+ }
329
+ writeOut(io, renderReleaseNotesBundle(pending.bundle));
330
+ // `unknown` exits 1, as `update --check` does on `unresolved` (CR134, decision 3).
331
+ return pending.bundle.unknown_reason === null ? 0 : 1;
296
332
  } else {
297
333
  // The notes ship in the package at the same path as in the checkout
298
334
  // (US3 D8), read from the tree that holds the running front door --
@@ -459,9 +495,13 @@ function runRuntimeUpdate(
459
495
  // The update lane asks npm, once, whether `-g` addresses this install: a
460
496
  // package outside npm's own global root is not the one `npm install -g`
461
497
  // would replace. The read routes do not pay for the probe.
498
+ //
499
+ // npm runs in the tree the front door runs from -- for a package, the
500
+ // install's own root -- and never in the cwd, whose `.npmrc` could name
501
+ // another registry or prefix (CR135). The strategy's own calls do the same.
462
502
  const install = detectInstallKind({
463
503
  frontDoorPath: frontDoorSelfPath(),
464
- npmRootGlobal: npmRootGlobal(),
504
+ npmRootGlobal: npmRootGlobal(runningTreeRoot()),
465
505
  });
466
506
  const start = install.kind === "clone" ? install.repository : cwd;
467
507
  const channel = channelFor(install, env, homedir(), channelOverride);
@@ -548,6 +588,16 @@ function runRuntimeUpdate(
548
588
  });
549
589
 
550
590
  writeOut(io, renderUpdateResult(result));
591
+ // The install now holds its channel's version: say so where the welcome card
592
+ // and the status line read it, or they keep announcing what was just
593
+ // installed (CR135).
594
+ if (result.success && !args.includes("--dry-run")) {
595
+ recordUpToDate(mirrorHome, {
596
+ channel: channel.value,
597
+ commit: install.kind === "clone" ? result.newRef : null,
598
+ checkedAt: isoNow(),
599
+ });
600
+ }
551
601
  if (repair && result.success) {
552
602
  // After the stages, not before them: the instruction is what to do NEXT,
553
603
  // and it reads as a caption on a result the operator has already seen.
@@ -596,6 +646,74 @@ function detectThisInstall(): InstallKind {
596
646
  return detectInstallKind({ frontDoorPath: frontDoorSelfPath() });
597
647
  }
598
648
 
649
+ /**
650
+ * `release-notes pending` for the install that is running, never for the cwd
651
+ * (CR134), which for a skill is the person's project. A clone reads its own
652
+ * channel's ref -- or `--ref` -- in its own repository; a package asks the
653
+ * registry what its channel holds; an install that cannot be identified has no
654
+ * source, and says so.
655
+ */
656
+ function pendingReleaseNotes(
657
+ args: readonly string[],
658
+ io: InstallRouteIo,
659
+ env: NodeJS.ProcessEnv,
660
+ version: string,
661
+ ): { bundle: ReleaseNotesBundle } | { usage: string } {
662
+ const install = io.install ?? detectThisInstall();
663
+ const osHome = io.osHome ?? homedir();
664
+ const ref = optionValue(args, "--ref");
665
+ const fetch = !args.includes("--no-fetch");
666
+
667
+ if (install.kind === "clone") {
668
+ return {
669
+ bundle: buildPendingReleaseNotes({
670
+ // `--from` omitted falls back to the running tree's own version.
671
+ currentVersion: optionValue(args, "--from") ?? version,
672
+ ref: ref ?? upstreamFor(channelFor(install, env, osHome)),
673
+ repository: install.repository,
674
+ fetch,
675
+ }),
676
+ };
677
+ }
678
+
679
+ if (install.kind === "package") {
680
+ if (ref !== null) {
681
+ return {
682
+ usage:
683
+ `release-notes pending: --ref names a git ref, and this install is a package ` +
684
+ `(${install.name}@${install.version}), which reads its channel from the npm registry\n`,
685
+ };
686
+ }
687
+ const currentVersion = optionValue(args, "--from") ?? install.version;
688
+ const channel = channelFor(install, env, osHome).value;
689
+ if (!fetch) {
690
+ return {
691
+ bundle: unknownReleaseNotes(
692
+ currentVersion,
693
+ `npm ${install.name}@${channel}`,
694
+ "a package keeps no copy of its channel to read offline; run without --no-fetch",
695
+ ),
696
+ };
697
+ }
698
+ return {
699
+ bundle: buildPendingReleaseNotesFromRegistry({
700
+ currentVersion,
701
+ install,
702
+ channel,
703
+ npm: io.npm ?? defaultNpmRunner,
704
+ }),
705
+ };
706
+ }
707
+
708
+ return {
709
+ bundle: unknownReleaseNotes(
710
+ optionValue(args, "--from") ?? version,
711
+ null,
712
+ `install kind unknown (${install.reason})`,
713
+ ),
714
+ };
715
+ }
716
+
599
717
  /**
600
718
  * argparse's positional for `release-notes`: the first bare token that is not
601
719
  * consumed as the VALUE of `--from` or `--ref`. Walking the list is the only
@@ -0,0 +1,166 @@
1
+ // `conversation-logger session-start --fast --welcome --format json` (CV9.E2.S32).
2
+ //
3
+ // The Pi extension used to open a session with three awaited front-door
4
+ // processes in a row -- `session-start --fast`, `welcome`, and
5
+ // `welcome --status-line` -- paying the loader's fixed cost three times
6
+ // before the user could type. They cannot run in parallel: the first WRITES
7
+ // the orientation the other two READ. They can run in one process, as long as
8
+ // the write commits before the reads. This route is that sequencing and
9
+ // nothing else.
10
+ //
11
+ // What it deliberately does not do:
12
+ //
13
+ // * It does not teach `loggerCli.ts` to render a welcome card. That module
14
+ // is the conversation domain; composition across domains is this layer's
15
+ // job.
16
+ // * It does not touch `runWelcomeRoute`'s read-only contract. Both renders
17
+ // run through that route with captured io, AFTER the write handle has
18
+ // closed, so the card and the status line are byte-identical to the
19
+ // standalone commands and never share the live write lock with the
20
+ // card's bounded update check.
21
+ // * It has no text form. The card is free text; the only safe delimiter is
22
+ // a JSON envelope.
23
+ //
24
+ // The envelope is for the extension, not for a skill. A skill that called this
25
+ // would put card text in front of the model through a channel the Ariad
26
+ // surface rules do not cover; `checkSkillCommandParity.ts` refuses the flag in
27
+ // every SKILL.md.
28
+
29
+ import type { WritableDatabase } from "#db/database.ts";
30
+ import { runConversationLoggerRoute } from "./conversationLoggerRoute.ts";
31
+ import { runWelcomeRoute } from "./runtimeRoute.ts";
32
+
33
+ export const SESSION_START_WELCOME_SCHEMA_VERSION = "1.0.0";
34
+
35
+ export interface SessionStartWelcomeEnvelope {
36
+ schemaVersion: typeof SESSION_START_WELCOME_SCHEMA_VERSION;
37
+ /** The logger's stdout lines, joined with `\n`: what `session-start --fast` prints. */
38
+ sessionStart: string;
39
+ /** `mirror welcome`'s stdout minus the trailing newline; `""` when disabled. */
40
+ welcome: string;
41
+ /** `mirror welcome --status-line`'s stdout minus the trailing newline; `""` when empty. */
42
+ statusLine: string;
43
+ }
44
+
45
+ export interface SessionStartWelcomeOutcome {
46
+ exitCode: number;
47
+ /** Content-free category for the front-door log when a render failed after the write. */
48
+ detail?: string;
49
+ }
50
+
51
+ export interface SessionStartWelcomeDeps {
52
+ /** The real write path: opens the live write database, runs `write`, closes it. */
53
+ withMirrorWriteDb: (
54
+ argv: readonly string[],
55
+ write: (db: WritableDatabase, dbPath: string) => Promise<number> | number,
56
+ ) => Promise<number>;
57
+ /**
58
+ * The welcome renderer. Defaults to the real route; the render-failure test
59
+ * injects one that throws. The write path is never injected: it is the real
60
+ * one in every test.
61
+ */
62
+ renderWelcome?: typeof runWelcomeRoute;
63
+ stdout?: (text: string) => void;
64
+ stderr?: (text: string) => void;
65
+ }
66
+
67
+ /** Only the `--fast --welcome` composite answers here; everything else is the plain logger route. */
68
+ export function isSessionStartWelcome(argv: readonly string[]): boolean {
69
+ return (
70
+ argv[0] === "conversation-logger" &&
71
+ argv.includes("session-start") &&
72
+ argv.includes("--welcome")
73
+ );
74
+ }
75
+
76
+ function optionValue(args: readonly string[], name: string): string | null {
77
+ const index = args.indexOf(name);
78
+ if (index === -1) return null;
79
+ return args[index + 1] ?? null;
80
+ }
81
+
82
+ /** The logger's `--mirror-home` reaches the welcome renders too, so both read the same home. */
83
+ function welcomeArgv(argv: readonly string[], statusLine: boolean): string[] {
84
+ const out = ["welcome"];
85
+ if (statusLine) out.push("--status-line");
86
+ const home = optionValue(argv, "--mirror-home");
87
+ if (home !== null) out.push("--mirror-home", home);
88
+ const sessionId = optionValue(argv, "--session-id");
89
+ if (statusLine && sessionId !== null) out.push("--session-id", sessionId);
90
+ return out;
91
+ }
92
+
93
+ /** The logger's own argv: the composite flags removed, `--format`'s value with it. */
94
+ function withoutCompositeFlags(argv: readonly string[]): string[] {
95
+ const out: string[] = [];
96
+ for (let i = 0; i < argv.length; i += 1) {
97
+ const arg = argv[i] as string;
98
+ if (arg === "--welcome") continue;
99
+ if (arg === "--format") {
100
+ i += 1;
101
+ continue;
102
+ }
103
+ out.push(arg);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function stripTrailingNewline(text: string): string {
109
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
110
+ }
111
+
112
+ export async function runSessionStartWelcomeRoute(
113
+ argv: readonly string[],
114
+ deps: SessionStartWelcomeDeps,
115
+ ): Promise<SessionStartWelcomeOutcome> {
116
+ const stdout = deps.stdout ?? ((text: string) => process.stdout.write(text));
117
+ const stderr = deps.stderr ?? ((text: string) => process.stderr.write(text));
118
+ const renderWelcome = deps.renderWelcome ?? runWelcomeRoute;
119
+
120
+ if (!argv.includes("--fast")) {
121
+ stderr("session-start: --welcome requires --fast (the full session-start runs maintenance)\n");
122
+ return { exitCode: 2 };
123
+ }
124
+ const format = optionValue(argv, "--format");
125
+ if (format !== "json") {
126
+ stderr(
127
+ "session-start: --welcome requires --format json (the card has no safe text delimiter)\n",
128
+ );
129
+ return { exitCode: 2 };
130
+ }
131
+
132
+ // 1. The write, through the real path. Its handle is closed when this returns.
133
+ const loggerArgv = withoutCompositeFlags(argv);
134
+ const sessionStartLines: string[] = [];
135
+ const exitCode = await deps.withMirrorWriteDb(loggerArgv, (db, dbPath) =>
136
+ runConversationLoggerRoute(db, dbPath, loggerArgv, {
137
+ stdout: (line) => sessionStartLines.push(line),
138
+ // The logger's refusals keep going to stderr, as they do standalone.
139
+ stderr: (line) => stderr(`${line}\n`),
140
+ }),
141
+ );
142
+ if (exitCode !== 0) return { exitCode };
143
+
144
+ // 2. The reads, after the write committed and its handle closed. A render
145
+ // failure must not lose the unmute: the envelope still goes out, with the
146
+ // failed field empty, and the category reaches the front-door log.
147
+ let detail: string | undefined;
148
+ const render = (statusLine: boolean): string => {
149
+ let captured = "";
150
+ try {
151
+ renderWelcome(welcomeArgv(argv, statusLine), { stdout: (text) => (captured += text) });
152
+ } catch (error) {
153
+ detail = `render-failed=${error instanceof Error ? error.name : "unknown"}`;
154
+ return "";
155
+ }
156
+ return stripTrailingNewline(captured);
157
+ };
158
+ const envelope: SessionStartWelcomeEnvelope = {
159
+ schemaVersion: SESSION_START_WELCOME_SCHEMA_VERSION,
160
+ sessionStart: sessionStartLines.join("\n"),
161
+ welcome: render(false),
162
+ statusLine: render(true),
163
+ };
164
+ stdout(`${JSON.stringify(envelope)}\n`);
165
+ return detail ? { exitCode: 0, detail } : { exitCode: 0 };
166
+ }
@@ -0,0 +1,37 @@
1
+ // Where a stdin-reading route gets its payload (CV21.DS2.US8).
2
+ //
3
+ // Two of the conversation logger's subcommands read a hook payload from
4
+ // stdin: `user-prompt` and `stop`. Until US8 the Claude hook entries did not
5
+ // touch fd 0 and let the route read it. Now an entry reads the payload first,
6
+ // to detect the runtime it is in (`runtimeDetection.ts`), and fd 0 cannot be
7
+ // read twice. So the entry hands the payload over here, and the route takes
8
+ // it before falling back to fd 0.
9
+ //
10
+ // A one-shot handoff rather than an argument because the hooks run the front
11
+ // door in-process (`runFrontDoor`), through the same `main(argv)` every other
12
+ // caller uses, and a payload does not belong in argv: argv is what `ps`
13
+ // shows, and what the Gemini hooks already put a prompt into is the exception
14
+ // this seam avoids repeating.
15
+
16
+ import { readFileSync } from "node:fs";
17
+
18
+ let provided: string | null = null;
19
+
20
+ /** Hand a payload to the next stdin-reading route in this process. */
21
+ export function provideStdin(text: string): void {
22
+ provided = text;
23
+ }
24
+
25
+ /** The provided payload, once; else fd 0 to its end; "" when there is none. */
26
+ export function takeStdin(): string {
27
+ if (provided !== null) {
28
+ const text = provided;
29
+ provided = null;
30
+ return text;
31
+ }
32
+ try {
33
+ return readFileSync(0, "utf-8");
34
+ } catch {
35
+ return "";
36
+ }
37
+ }