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.
- package/.claude/hooks/log-assistant-reply.sh +85 -0
- package/.pi/extensions/mirror-logger.ts +107 -22
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.0.md +164 -0
- package/docs/releases/v0.33.1.md +174 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +6 -2
- package/plugins/mirror-mind/hooks/hooks.json +18 -6
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
- package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +4 -2
- package/ts/src/conversation/hookPayload.ts +51 -0
- package/ts/src/conversation/logger.ts +300 -25
- package/ts/src/conversation/loggerCli.ts +41 -2
- package/ts/src/frontDoor/cli.ts +8 -1
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -10
- package/ts/src/frontDoor/routing.ts +2 -0
- package/ts/src/frontDoor/runtimeRoute.ts +142 -24
- package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
- package/ts/src/frontDoor/stdinSource.ts +37 -0
- package/ts/src/guards/claudePlugin.ts +56 -10
- package/ts/src/guards/packContents.ts +1 -1
- package/ts/src/hooks/claude.ts +52 -14
- package/ts/src/hooks/payload.ts +18 -13
- package/ts/src/hooks/runtime.ts +24 -4
- package/ts/src/hooks/runtimeDetection.ts +34 -0
- package/ts/src/mirror/mirrorForm.ts +43 -0
- package/ts/src/runtime/git.ts +8 -2
- package/ts/src/runtime/releaseNotes.ts +230 -52
- package/ts/src/runtime/strategies/clone.ts +10 -3
- package/ts/src/runtime/strategies/package.ts +112 -23
- package/ts/src/util/tarEntries.ts +128 -0
- package/ts/src/welcome/card.ts +182 -60
- package/ts/src/welcome/statusLine.ts +12 -2
- package/ts/src/welcome/updateCache.ts +45 -27
- 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([
|
|
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
|
-
|
|
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,
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -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))
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
/**
|
|
169
|
-
|
|
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
|
|
188
|
-
const card = composeWelcome({
|
|
214
|
+
const { card, check } = composeWelcome({
|
|
189
215
|
mirrorHome: home,
|
|
190
|
-
|
|
191
|
-
treeRoot: runningTreeRoot(),
|
|
216
|
+
install: io.install ?? detectThisInstall(),
|
|
192
217
|
env,
|
|
193
218
|
version,
|
|
194
|
-
|
|
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:
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|