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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Named entries out of a gzipped tar archive, in memory. (CR134)
|
|
2
|
+
//
|
|
3
|
+
// A package's pending release notes are read from the channel version's
|
|
4
|
+
// tarball, which `npm pack` fetched across a trust boundary: the registry is
|
|
5
|
+
// whatever the npm config names, and a project's `.npmrc` can name it (CR135).
|
|
6
|
+
// So nothing here writes a file, and no entry name becomes a path or an
|
|
7
|
+
// argument -- the caller asks for names and gets bytes. That is also why `tar`
|
|
8
|
+
// is not spawned: it would take archive-chosen member names as arguments.
|
|
9
|
+
// Inflation stops at a bound, because a small gzip can inflate to gigabytes.
|
|
10
|
+
//
|
|
11
|
+
// What is read is what tar writers produce: ustar headers with the POSIX
|
|
12
|
+
// `prefix` field, pax extended headers (`x`, whose `path` names the next
|
|
13
|
+
// entry), and GNU long names (`L`). Every header's checksum is checked, and an
|
|
14
|
+
// archive that stops before its end-of-archive block is refused rather than
|
|
15
|
+
// half-read: a tarball cut between two entries would otherwise lose the later
|
|
16
|
+
// ones -- the newest note among them -- without a word.
|
|
17
|
+
|
|
18
|
+
import { gunzipSync } from "node:zlib";
|
|
19
|
+
|
|
20
|
+
const BLOCK = 512;
|
|
21
|
+
const USTAR_MAGIC = Buffer.from("ustar\0", "latin1");
|
|
22
|
+
|
|
23
|
+
/** The bound on inflated bytes. A release tarball inflates to a few MB. */
|
|
24
|
+
export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
export type TarEntries = { ok: true; entries: Map<string, Buffer> } | { ok: false; detail: string };
|
|
27
|
+
|
|
28
|
+
/** A NUL-terminated header field. */
|
|
29
|
+
function field(bytes: Buffer): string {
|
|
30
|
+
const end = bytes.indexOf(0);
|
|
31
|
+
return bytes.subarray(0, end === -1 ? bytes.length : end).toString("utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An octal header number, or null when the field holds anything else. */
|
|
35
|
+
function octal(bytes: Buffer): number | null {
|
|
36
|
+
const value = field(bytes).trim();
|
|
37
|
+
return /^[0-7]+$/.test(value) ? Number.parseInt(value, 8) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The checksum counts the header with its own field read as spaces. */
|
|
41
|
+
function checksumHolds(header: Buffer): boolean {
|
|
42
|
+
const recorded = octal(header.subarray(148, 156));
|
|
43
|
+
if (recorded === null) return false;
|
|
44
|
+
let sum = 0;
|
|
45
|
+
for (let index = 0; index < BLOCK; index += 1) {
|
|
46
|
+
sum += index >= 148 && index < 156 ? 0x20 : (header[index] as number);
|
|
47
|
+
}
|
|
48
|
+
return sum === recorded;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The `path` record of a pax extended header: records are `<len> <key>=<value>\n`. */
|
|
52
|
+
function paxPath(data: Buffer): string | null {
|
|
53
|
+
let path: string | null = null;
|
|
54
|
+
let offset = 0;
|
|
55
|
+
while (offset < data.length) {
|
|
56
|
+
const space = data.indexOf(0x20, offset);
|
|
57
|
+
if (space === -1) break;
|
|
58
|
+
const length = Number.parseInt(data.subarray(offset, space).toString("latin1"), 10);
|
|
59
|
+
if (!Number.isInteger(length) || length <= 0 || offset + length > data.length) break;
|
|
60
|
+
const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
|
|
61
|
+
const equals = record.indexOf("=");
|
|
62
|
+
if (equals !== -1 && record.slice(0, equals) === "path") path = record.slice(equals + 1);
|
|
63
|
+
offset += length;
|
|
64
|
+
}
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The regular-file entries of a gzipped tar whose names `wanted` accepts.
|
|
70
|
+
* Refuses, with a reason, an archive that inflates past the bound, is not
|
|
71
|
+
* gzip, fails a header checksum, or ends inside an entry.
|
|
72
|
+
*/
|
|
73
|
+
export function readTarGzEntries(
|
|
74
|
+
gz: Buffer,
|
|
75
|
+
wanted: (name: string) => boolean,
|
|
76
|
+
maxInflatedBytes: number = MAX_INFLATED_BYTES,
|
|
77
|
+
): TarEntries {
|
|
78
|
+
let tar: Buffer;
|
|
79
|
+
try {
|
|
80
|
+
tar = gunzipSync(gz, { maxOutputLength: maxInflatedBytes });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error as { code?: string }).code === "ERR_BUFFER_TOO_LARGE") {
|
|
83
|
+
return { ok: false, detail: `the archive inflates to more than ${maxInflatedBytes} bytes` };
|
|
84
|
+
}
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
return { ok: false, detail: `unreadable gzip: ${message}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const entries = new Map<string, Buffer>();
|
|
90
|
+
let nextName: string | null = null;
|
|
91
|
+
let offset = 0;
|
|
92
|
+
while (offset + BLOCK <= tar.length) {
|
|
93
|
+
const header = tar.subarray(offset, offset + BLOCK);
|
|
94
|
+
if (header.every((byte) => byte === 0)) return { ok: true, entries };
|
|
95
|
+
if (!checksumHolds(header)) {
|
|
96
|
+
return { ok: false, detail: `the tar header at byte ${offset} fails its checksum` };
|
|
97
|
+
}
|
|
98
|
+
const size = octal(header.subarray(124, 136));
|
|
99
|
+
if (size === null) {
|
|
100
|
+
return { ok: false, detail: `the tar header at byte ${offset} has an unreadable size` };
|
|
101
|
+
}
|
|
102
|
+
const start = offset + BLOCK;
|
|
103
|
+
const end = start + size;
|
|
104
|
+
if (end > tar.length) return { ok: false, detail: "the archive is truncated" };
|
|
105
|
+
const data = tar.subarray(start, end);
|
|
106
|
+
const type = String.fromCharCode(header[156] as number);
|
|
107
|
+
|
|
108
|
+
if (type === "x") {
|
|
109
|
+
nextName = paxPath(data) ?? nextName;
|
|
110
|
+
} else if (type === "L") {
|
|
111
|
+
nextName = field(data);
|
|
112
|
+
} else if (type !== "g") {
|
|
113
|
+
const prefix = header.subarray(257, 263).equals(USTAR_MAGIC)
|
|
114
|
+
? field(header.subarray(345, 500))
|
|
115
|
+
: "";
|
|
116
|
+
const base = field(header.subarray(0, 100));
|
|
117
|
+
const name = nextName ?? (prefix ? `${prefix}/${base}` : base);
|
|
118
|
+
nextName = null;
|
|
119
|
+
if ((type === "0" || type === "\0") && wanted(name)) entries.set(name, Buffer.from(data));
|
|
120
|
+
}
|
|
121
|
+
offset = start + Math.ceil(size / BLOCK) * BLOCK;
|
|
122
|
+
}
|
|
123
|
+
// The archive stopped before its end-of-archive block: inside an entry's
|
|
124
|
+
// padding, or cleanly between two entries. Entries after the last whole one
|
|
125
|
+
// may be missing, so nothing read here is presented as the whole (CI at
|
|
126
|
+
// 565b7b97 met the first; the second is the general case).
|
|
127
|
+
return { ok: false, detail: "the archive is truncated" };
|
|
128
|
+
}
|
package/ts/src/welcome/card.ts
CHANGED
|
@@ -2,36 +2,53 @@
|
|
|
2
2
|
// `compose_welcome` from `src/memory/cli/welcome.py`.
|
|
3
3
|
//
|
|
4
4
|
// Unlike the status line, the card MAY reach the network: on the stable
|
|
5
|
-
// channel it performs a bounded remote update check so a
|
|
6
|
-
// release becomes visible
|
|
7
|
-
//
|
|
5
|
+
// channel it performs a bounded remote update check at every render, so a
|
|
6
|
+
// newly published release becomes visible at the next session start. The
|
|
7
|
+
// check is switched off entirely by
|
|
8
8
|
// `MIRROR_WELCOME_REMOTE_UPDATE_CHECK=off|0|false|no`.
|
|
9
9
|
//
|
|
10
|
+
// The check asks the RUNNING INSTALL, never the cwd (CR135). Pi renders the
|
|
11
|
+
// card in the person's project, and the oracle asked whatever repository that
|
|
12
|
+
// was: a project with a tagged `origin/stable` made the card announce its tag.
|
|
13
|
+
// A package now reads its channel's dist-tag, as `runtime update --check`
|
|
14
|
+
// does, with npm run in the install's own root; a clone asks its own checkout
|
|
15
|
+
// and its own remote. One budget bounds the remote reads of a render.
|
|
16
|
+
//
|
|
10
17
|
// Date rendering is done by hand from `_MONTH_ABBR`, never through
|
|
11
18
|
// `toLocaleDateString`: the oracle formats the month itself, so a locale-aware
|
|
12
19
|
// TypeScript formatter would render "since ago. 2025" on a machine whose
|
|
13
20
|
// locale is not English and call it parity.
|
|
14
21
|
|
|
15
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
16
24
|
import { basename, join } from "node:path";
|
|
17
25
|
import { type Database, openDatabaseReadOnly } from "#db/database.ts";
|
|
18
26
|
import { dbNameForEnv } from "#frontDoor/dbPath.ts";
|
|
19
27
|
import { listJourneysForListCommand } from "#identity/journeyListing.ts";
|
|
28
|
+
import { channelFor } from "#runtime/channel.ts";
|
|
20
29
|
import {
|
|
21
30
|
checkUpdateAvailability,
|
|
31
|
+
GIT_NETWORK_TIMEOUT_MS,
|
|
22
32
|
type GitStatus,
|
|
23
33
|
inspectGit,
|
|
24
34
|
inspectGitUpdatePlan,
|
|
25
35
|
type MarkerValue,
|
|
36
|
+
NO_REPOSITORY,
|
|
26
37
|
runGit,
|
|
27
38
|
} from "#runtime/git.ts";
|
|
39
|
+
import type { InstallKind } from "#runtime/installKind.ts";
|
|
40
|
+
import {
|
|
41
|
+
checkPackageUpdateAvailability,
|
|
42
|
+
defaultNpmRunner,
|
|
43
|
+
type NpmRunner,
|
|
44
|
+
} from "#runtime/strategies/package.ts";
|
|
28
45
|
import { compareSemver } from "#util/semver.ts";
|
|
29
46
|
import { SEPARATOR } from "./statusLine.ts";
|
|
30
47
|
import {
|
|
31
|
-
|
|
32
|
-
cacheShouldRefresh,
|
|
48
|
+
isoNow,
|
|
33
49
|
readUpdateCache,
|
|
34
50
|
type UpdateAwareness,
|
|
51
|
+
verdictApplies,
|
|
35
52
|
writeUpdateCache,
|
|
36
53
|
} from "./updateCache.ts";
|
|
37
54
|
|
|
@@ -159,16 +176,21 @@ function commitsMatch(candidate: string, target: string): boolean {
|
|
|
159
176
|
* Port of `_remote_tag_for_commit`: which published tag names the commit the
|
|
160
177
|
* remote is at. `^{}` refs are the dereferenced peel of an annotated tag and
|
|
161
178
|
* are skipped, so a tag is not counted twice.
|
|
179
|
+
*
|
|
180
|
+
* A network read: it ran on git's 2 s LOCAL budget until CR135, so a lookup
|
|
181
|
+
* slower than that cached an update without its version. The card passes
|
|
182
|
+
* what is left of its own budget.
|
|
162
183
|
*/
|
|
163
184
|
export function remoteTagForCommit(
|
|
164
185
|
upstream: string,
|
|
165
186
|
remoteCommit: string,
|
|
166
187
|
repository: string | null,
|
|
188
|
+
timeoutMs: number = GIT_NETWORK_TIMEOUT_MS,
|
|
167
189
|
): string | null {
|
|
168
190
|
if (!upstream.includes("/")) return null;
|
|
169
191
|
const remote = upstream.split("/", 1)[0] as string;
|
|
170
192
|
if (repository === null) return null;
|
|
171
|
-
const result = runGit(["ls-remote", "--tags", remote, "refs/tags/v*"], repository);
|
|
193
|
+
const result = runGit(["ls-remote", "--tags", remote, "refs/tags/v*"], repository, timeoutMs);
|
|
172
194
|
if (result.code !== 0 || !result.stdout) return null;
|
|
173
195
|
|
|
174
196
|
const matches: string[] = [];
|
|
@@ -206,76 +228,174 @@ export function localReleaseTitle(version: string | null, treeRoot: string): str
|
|
|
206
228
|
return null;
|
|
207
229
|
}
|
|
208
230
|
|
|
231
|
+
/** How long the card's remote reads may take, together (CR135, decision 1). */
|
|
232
|
+
export const WELCOME_REMOTE_BUDGET_MS = 5_000;
|
|
233
|
+
|
|
209
234
|
export interface WelcomeOptions {
|
|
210
235
|
/** The resolved mirror home. Null renders "", as in the oracle. */
|
|
211
236
|
mirrorHome: string | null;
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
/**
|
|
238
|
+
* The running install, detected from where the front door lives. The card's
|
|
239
|
+
* channel, its update check, and its "commits behind" line are this
|
|
240
|
+
* install's, whatever the cwd (CR135).
|
|
241
|
+
*/
|
|
242
|
+
install: InstallKind;
|
|
216
243
|
env?: NodeJS.ProcessEnv;
|
|
217
244
|
version: string;
|
|
218
|
-
|
|
245
|
+
/** The OS home whose config directory holds a package's channel file. */
|
|
246
|
+
osHome?: string;
|
|
247
|
+
/** The npm seam, injected as the update route's is. */
|
|
248
|
+
npm?: NpmRunner;
|
|
249
|
+
/** The budget of the remote reads; production takes `WELCOME_REMOTE_BUDGET_MS`. */
|
|
250
|
+
remoteBudgetMs?: number;
|
|
219
251
|
/** Injected only so the written cache is byte-comparable; production omits it. */
|
|
220
252
|
now?: () => string;
|
|
221
253
|
}
|
|
222
254
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
/** What one render's remote check asked, how it answered, and how long it took. */
|
|
256
|
+
export interface RemoteCheck {
|
|
257
|
+
source: "registry" | "git";
|
|
258
|
+
verdict: string;
|
|
259
|
+
ms: number;
|
|
226
260
|
}
|
|
227
261
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
262
|
+
export interface WelcomeRender {
|
|
263
|
+
card: string;
|
|
264
|
+
/** Null when no remote check ran. */
|
|
265
|
+
check: RemoteCheck | null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface CheckSeams {
|
|
269
|
+
npm: NpmRunner;
|
|
270
|
+
budgetMs: number;
|
|
271
|
+
now: () => string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type CheckedInstall = Exclude<InstallKind, { kind: "unknown" }>;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A package's verdict: its channel's dist-tag, read as `runtime update
|
|
278
|
+
* --check` reads it, in the install's own root. One read, so it gets the
|
|
279
|
+
* whole budget, and no retries: unanswered, npm retries for over a minute.
|
|
280
|
+
*/
|
|
281
|
+
function checkPackage(
|
|
282
|
+
install: Extract<InstallKind, { kind: "package" }>,
|
|
283
|
+
channel: MarkerValue,
|
|
284
|
+
seams: CheckSeams,
|
|
285
|
+
): UpdateAwareness {
|
|
286
|
+
const report = checkPackageUpdateAvailability(install, channel, seams.npm, {
|
|
287
|
+
timeoutMs: seams.budgetMs,
|
|
288
|
+
fetchRetries: 0,
|
|
289
|
+
});
|
|
290
|
+
const version =
|
|
291
|
+
report.status === "update_available" && report.resolved !== null ? `v${report.resolved}` : null;
|
|
292
|
+
return {
|
|
293
|
+
// `unresolved` is the cache's `unknown`: the check could not answer.
|
|
294
|
+
availability: report.status === "unresolved" ? "unknown" : report.status,
|
|
295
|
+
checked_at: seams.now(),
|
|
296
|
+
channel: channel.value,
|
|
297
|
+
current_commit: null,
|
|
298
|
+
remote_commit: null,
|
|
299
|
+
version,
|
|
300
|
+
title: localReleaseTitle(version, install.root),
|
|
301
|
+
note: report.note,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Port of `_refresh_update_cache`'s question, asked of the clone's own
|
|
307
|
+
* checkout. The `ls-remote` and then the tag lookup share one deadline.
|
|
308
|
+
*/
|
|
309
|
+
function checkClone(
|
|
310
|
+
repository: string,
|
|
231
311
|
channel: MarkerValue,
|
|
232
|
-
cwd: string,
|
|
233
|
-
treeRoot: string,
|
|
234
312
|
version: string,
|
|
235
|
-
|
|
236
|
-
): UpdateAwareness
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
313
|
+
seams: CheckSeams,
|
|
314
|
+
): UpdateAwareness {
|
|
315
|
+
const deadline = Date.now() + seams.budgetMs;
|
|
316
|
+
// At least 1 ms: a spawn timeout of 0 is no timeout at all.
|
|
317
|
+
const report = checkUpdateAvailability(
|
|
318
|
+
repository,
|
|
319
|
+
channel.value,
|
|
320
|
+
version,
|
|
321
|
+
Math.max(1, deadline - Date.now()),
|
|
322
|
+
);
|
|
243
323
|
let tag: string | null = null;
|
|
244
|
-
let title: string | null = null;
|
|
245
324
|
if (report.status === "update_available" && report.remote_commit && report.upstream) {
|
|
246
|
-
|
|
247
|
-
|
|
325
|
+
const left = deadline - Date.now();
|
|
326
|
+
if (left >= 1)
|
|
327
|
+
tag = remoteTagForCommit(report.upstream, report.remote_commit, repository, left);
|
|
248
328
|
}
|
|
249
|
-
|
|
329
|
+
return {
|
|
250
330
|
availability: report.status,
|
|
251
|
-
checked_at: now(),
|
|
331
|
+
checked_at: seams.now(),
|
|
252
332
|
channel: channel.value,
|
|
253
333
|
current_commit: report.local_commit,
|
|
254
334
|
remote_commit: report.remote_commit,
|
|
255
335
|
version: tag,
|
|
256
|
-
title,
|
|
336
|
+
title: localReleaseTitle(tag, repository),
|
|
257
337
|
note: report.note,
|
|
258
338
|
};
|
|
259
|
-
|
|
260
|
-
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function checkInstall(
|
|
342
|
+
install: CheckedInstall,
|
|
343
|
+
channel: MarkerValue,
|
|
344
|
+
version: string,
|
|
345
|
+
seams: CheckSeams,
|
|
346
|
+
): UpdateAwareness {
|
|
347
|
+
try {
|
|
348
|
+
return install.kind === "package"
|
|
349
|
+
? checkPackage(install, channel, seams)
|
|
350
|
+
: checkClone(install.repository, channel, version, seams);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return {
|
|
353
|
+
availability: "unknown",
|
|
354
|
+
checked_at: seams.now(),
|
|
355
|
+
channel: channel.value,
|
|
356
|
+
current_commit: null,
|
|
357
|
+
remote_commit: null,
|
|
358
|
+
version: null,
|
|
359
|
+
title: null,
|
|
360
|
+
note: error instanceof Error ? error.message : String(error),
|
|
361
|
+
};
|
|
261
362
|
}
|
|
262
|
-
return awareness;
|
|
263
363
|
}
|
|
264
364
|
|
|
265
|
-
/**
|
|
365
|
+
/**
|
|
366
|
+
* Port of `_update_awareness`, asking the install instead of the cwd.
|
|
367
|
+
*
|
|
368
|
+
* On `stable` the install is asked at every render; on `main`, never. Only
|
|
369
|
+
* `up_to_date` and `update_available` are written. A check that could not
|
|
370
|
+
* answer (`unknown`) leaves the cache as it is, and the card shows what the
|
|
371
|
+
* cache knows, as the status line does. Any other answer (`local_ahead`,
|
|
372
|
+
* `diverged`, `channel_behind`) is an answer: no update to take, and nothing
|
|
373
|
+
* written.
|
|
374
|
+
*/
|
|
266
375
|
export function updateAwareness(
|
|
267
376
|
homePath: string,
|
|
377
|
+
install: InstallKind,
|
|
268
378
|
channel: MarkerValue,
|
|
269
|
-
cwd: string,
|
|
270
379
|
version: string,
|
|
271
380
|
env: NodeJS.ProcessEnv,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
): UpdateAwareness | null {
|
|
381
|
+
seams: CheckSeams,
|
|
382
|
+
): { awareness: UpdateAwareness | null; check: RemoteCheck | null } {
|
|
275
383
|
const cached = readUpdateCache(homePath);
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
384
|
+
if (remoteUpdateCheckDisabled(env) || channel.value !== "stable" || install.kind === "unknown") {
|
|
385
|
+
return { awareness: cached, check: null };
|
|
386
|
+
}
|
|
387
|
+
const started = Date.now();
|
|
388
|
+
const fresh = checkInstall(install, channel, version, seams);
|
|
389
|
+
const check: RemoteCheck = {
|
|
390
|
+
source: install.kind === "package" ? "registry" : "git",
|
|
391
|
+
verdict: fresh.availability,
|
|
392
|
+
ms: Date.now() - started,
|
|
393
|
+
};
|
|
394
|
+
if (fresh.availability === "up_to_date" || fresh.availability === "update_available") {
|
|
395
|
+
writeUpdateCache(homePath, fresh);
|
|
396
|
+
return { awareness: fresh, check };
|
|
397
|
+
}
|
|
398
|
+
return { awareness: fresh.availability === "unknown" ? cached : fresh, check };
|
|
279
399
|
}
|
|
280
400
|
|
|
281
401
|
/** Port of `_update_line`. */
|
|
@@ -315,25 +435,27 @@ export function updateLine(
|
|
|
315
435
|
* mirror home: a fresh database with nothing in it still renders the full card
|
|
316
436
|
* with zeroes.
|
|
317
437
|
*/
|
|
318
|
-
export function composeWelcome(options: WelcomeOptions):
|
|
438
|
+
export function composeWelcome(options: WelcomeOptions): WelcomeRender {
|
|
319
439
|
const homePath = options.mirrorHome;
|
|
320
|
-
if (homePath === null) return "";
|
|
321
|
-
const cwd = options.cwd ?? process.cwd();
|
|
440
|
+
if (homePath === null) return { card: "", check: null };
|
|
322
441
|
const env = options.env ?? process.env;
|
|
323
|
-
const
|
|
442
|
+
const install = options.install;
|
|
443
|
+
// Nothing here reads the cwd (CR135): the channel is the one `runtime
|
|
444
|
+
// channel` reports for this install, and so is everything below.
|
|
445
|
+
const channel = channelFor(install, env, options.osHome ?? homedir());
|
|
324
446
|
|
|
325
447
|
const dbPath = join(homePath, dbNameForEnv(env.MEMORY_ENV || "production"));
|
|
326
448
|
const stats = formatStats(readWelcomeStats(existsSync(dbPath) ? dbPath : null));
|
|
327
|
-
const versionLine = `${options.version} · channel ${
|
|
328
|
-
const awareness = updateAwareness(
|
|
329
|
-
|
|
330
|
-
options.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
);
|
|
337
|
-
const update = updateLine(
|
|
338
|
-
return renderWelcome(basename(homePath), stats, versionLine, update);
|
|
449
|
+
const versionLine = `${options.version} · channel ${channel.value}`;
|
|
450
|
+
const { awareness, check } = updateAwareness(homePath, install, channel, options.version, env, {
|
|
451
|
+
npm: options.npm ?? defaultNpmRunner,
|
|
452
|
+
budgetMs: options.remoteBudgetMs ?? WELCOME_REMOTE_BUDGET_MS,
|
|
453
|
+
now: options.now ?? isoNow,
|
|
454
|
+
});
|
|
455
|
+
// A cached update the install already has is not shown (CR135).
|
|
456
|
+
const shown = awareness !== null && verdictApplies(awareness, options.version) ? awareness : null;
|
|
457
|
+
// A package has no checkout to be behind: its "commits behind" line is none.
|
|
458
|
+
const git = install.kind === "clone" ? inspectGit(install.repository) : NO_REPOSITORY;
|
|
459
|
+
const update = updateLine(channel, shown, git);
|
|
460
|
+
return { card: renderWelcome(basename(homePath), stats, versionLine, update), check };
|
|
339
461
|
}
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// It reads the update CACHE, never the remote -- refreshing the cache is the
|
|
11
11
|
// full card's job, and the one time that budget was allowed to leak into the
|
|
12
12
|
// per-turn path it cost a 120s network timeout on a status bar.
|
|
13
|
+
//
|
|
14
|
+
// Since CR135 it also drops a cached update the install already has. The
|
|
15
|
+
// installed version is the caller's: the route reads it from the running
|
|
16
|
+
// tree's manifest, a file read, so this module still spawns nothing.
|
|
13
17
|
|
|
14
18
|
import { existsSync } from "node:fs";
|
|
15
19
|
import { basename, join } from "node:path";
|
|
@@ -18,13 +22,15 @@ import { dbNameForEnv } from "#frontDoor/dbPath.ts";
|
|
|
18
22
|
import { getIdentityContent } from "#identity/identityRead.ts";
|
|
19
23
|
import { GLOBAL_STICKY_DEFAULTS_SESSION_ID, getRuntimeSession } from "#mirror/runtimeSession.ts";
|
|
20
24
|
import { getActiveOperatingMode, modeLabel } from "#mode/operatingMode.ts";
|
|
21
|
-
import { readUpdateCache } from "./updateCache.ts";
|
|
25
|
+
import { readUpdateCache, verdictApplies } from "./updateCache.ts";
|
|
22
26
|
|
|
23
27
|
export const SEPARATOR = " · ";
|
|
24
28
|
|
|
25
29
|
export interface StatusLineOptions {
|
|
26
30
|
/** The resolved mirror home. Null renders nothing, as in the oracle. */
|
|
27
31
|
mirrorHome: string | null;
|
|
32
|
+
/** The running install's version: a cached update to it, or to an older one, is not shown. */
|
|
33
|
+
installedVersion: string;
|
|
28
34
|
sessionId?: string | null;
|
|
29
35
|
env?: NodeJS.ProcessEnv;
|
|
30
36
|
}
|
|
@@ -92,7 +98,11 @@ export function composeStatusLine(options: StatusLineOptions): string {
|
|
|
92
98
|
if (mode) parts.push(mode);
|
|
93
99
|
|
|
94
100
|
const awareness = readUpdateCache(homePath);
|
|
95
|
-
if (
|
|
101
|
+
if (
|
|
102
|
+
awareness &&
|
|
103
|
+
awareness.availability === "update_available" &&
|
|
104
|
+
verdictApplies(awareness, options.installedVersion)
|
|
105
|
+
) {
|
|
96
106
|
parts.push(`⬆${awareness.version ? ` ${awareness.version}` : ""}`);
|
|
97
107
|
} else {
|
|
98
108
|
parts.push("✓");
|
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
// nicety: two cores that serialize the same state differently would rewrite the
|
|
8
8
|
// file on every alternating invocation and thrash the 6h TTL, turning a bounded
|
|
9
9
|
// remote check into one on every turn.
|
|
10
|
+
//
|
|
11
|
+
// CR135 removed that TTL, which gated nothing: on `stable` every verdict the
|
|
12
|
+
// card writes is refreshed at the next render, and on `main` the card never
|
|
13
|
+
// checks. What a reader needs instead is to know whether a verdict still
|
|
14
|
+
// APPLIES to the install reading it (`verdictApplies`), and a successful
|
|
15
|
+
// `runtime update` now says what it installed (`recordUpToDate`).
|
|
10
16
|
|
|
11
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
18
|
import { dirname, join } from "node:path";
|
|
13
|
-
import type { MarkerValue } from "#runtime/git.ts";
|
|
14
19
|
import { pythonJsonDumpsIndented } from "#util/pyGenerators.ts";
|
|
20
|
+
import { compareSemver } from "#util/semver.ts";
|
|
15
21
|
|
|
16
22
|
export const UPDATE_CHECK_CACHE = "runtime/update-check.json";
|
|
17
|
-
export const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
18
23
|
|
|
19
24
|
export interface UpdateAwareness {
|
|
20
25
|
availability: string;
|
|
@@ -92,38 +97,51 @@ export function writeUpdateCache(homePath: string, awareness: UpdateAwareness):
|
|
|
92
97
|
}
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
/** Port of `
|
|
96
|
-
export function
|
|
97
|
-
|
|
98
|
-
const parsed = Date.parse(value.replace("Z", "+00:00"));
|
|
99
|
-
return Number.isNaN(parsed) ? null : new Date(parsed);
|
|
100
|
+
/** Port of `_iso_now`: the cache's timestamp shape, microseconds and all. */
|
|
101
|
+
export function isoNow(): string {
|
|
102
|
+
return new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000+00:00");
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|
|
103
|
-
*
|
|
104
|
-
* fresh -- the safe direction for a cache that gates a network call.
|
|
106
|
+
* Does a cached verdict still apply to the install reading it? (CR135)
|
|
105
107
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* One verdict goes stale in a way the reader can see: an update to a version
|
|
109
|
+
* the install already has, or has passed -- after an update, after a manual
|
|
110
|
+
* install, or when another install over the same home wrote it. Such a
|
|
111
|
+
* verdict is not shown, by the card or by the status line. A verdict without
|
|
112
|
+
* a version, from a clone's tag lookup that found none, cannot be judged and
|
|
113
|
+
* stands. A version that does not parse as `vX.Y.Z` sorts below every real one
|
|
114
|
+
* (`#util/semver.ts`), so it is never newer and is not shown.
|
|
109
115
|
*/
|
|
110
|
-
export function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
export function verdictApplies(awareness: UpdateAwareness, installedVersion: string): boolean {
|
|
117
|
+
if (awareness.availability !== "update_available" || awareness.version === null) return true;
|
|
118
|
+
return compareSemver(awareness.version, installedVersion) > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface UpToDateRecord {
|
|
122
|
+
/** The channel the update followed. */
|
|
123
|
+
channel: string;
|
|
124
|
+
/** A clone's commit after the update; null for a package, which has none. */
|
|
125
|
+
commit: string | null;
|
|
126
|
+
checkedAt: string;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
130
|
+
* What a successful `runtime update` knows, written where the card and the
|
|
131
|
+
* status line read it (CR135): the install now holds its channel's version.
|
|
132
|
+
* Until CR135 nothing rewrote the cache after an update, so the status line
|
|
133
|
+
* kept announcing the version just installed until some later check, from
|
|
134
|
+
* some repository, said otherwise.
|
|
123
135
|
*/
|
|
124
|
-
export function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
export function recordUpToDate(homePath: string, record: UpToDateRecord): void {
|
|
137
|
+
writeUpdateCache(homePath, {
|
|
138
|
+
availability: "up_to_date",
|
|
139
|
+
checked_at: record.checkedAt,
|
|
140
|
+
channel: record.channel,
|
|
141
|
+
current_commit: record.commit,
|
|
142
|
+
remote_commit: record.commit,
|
|
143
|
+
version: null,
|
|
144
|
+
title: null,
|
|
145
|
+
note: null,
|
|
146
|
+
});
|
|
129
147
|
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Mirror Mind plugin — MCP server launcher (CV22.DS9.TS2, DS9 decision D5;
|
|
3
|
-
# CV22.DS10.US3 plateau 3, decision D5).
|
|
4
|
-
#
|
|
5
|
-
# The manifest points here instead of at an engine, so the entry point can
|
|
6
|
-
# change without editing a plugin inside someone's runtime.
|
|
7
|
-
#
|
|
8
|
-
# A plugin is copied into Claude Code's own cache, so no relative path from
|
|
9
|
-
# this file reaches the Mirror tree. The launcher finds the installed `mirror`
|
|
10
|
-
# bin the way the plugin's hook wrappers find `mirror-hook`: MIRROR_BIN (the
|
|
11
|
-
# directory holding both, `$(npm prefix -g)/bin`), then the PATH, then the
|
|
12
|
-
# global bin directories a GUI launch leaves off it. Node is found the same
|
|
13
|
-
# way the wrappers find it, MIRROR_NODE first.
|
|
14
|
-
#
|
|
15
|
-
# Unlike a hook, a server that cannot start has no turn to protect: it says
|
|
16
|
-
# why on stderr, which is the client's log for this server, and exits 1.
|
|
17
|
-
# `runtime diagnose` reports a missing `mirror` from the caller's shell.
|
|
18
|
-
#
|
|
19
|
-
# No flags: `mirror mcp` reads configuration and silences `node:sqlite`'s
|
|
20
|
-
# ExperimentalWarning in-process (US3, D3), precisely because no MCP client
|
|
21
|
-
# will pass --no-warnings and stderr must stay the client's log.
|
|
22
|
-
|
|
23
|
-
set -u
|
|
24
|
-
|
|
25
|
-
ENTRY=""
|
|
26
|
-
[ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror" ] && ENTRY="$MIRROR_BIN/mirror"
|
|
27
|
-
[ -n "$ENTRY" ] || ENTRY="$(command -v mirror 2>/dev/null || true)"
|
|
28
|
-
for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
|
|
29
|
-
[ -n "$ENTRY" ] && break
|
|
30
|
-
[ -x "$candidate/mirror" ] && ENTRY="$candidate/mirror"
|
|
31
|
-
done
|
|
32
|
-
|
|
33
|
-
if [ -z "$ENTRY" ]; then
|
|
34
|
-
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
|
|
35
|
-
exit 1
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
NODE="${MIRROR_NODE:-}"
|
|
39
|
-
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
40
|
-
NODE="$(command -v node 2>/dev/null || true)"
|
|
41
|
-
fi
|
|
42
|
-
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
43
|
-
[ -n "$NODE" ] && break
|
|
44
|
-
[ -x "$candidate" ] && NODE="$candidate"
|
|
45
|
-
done
|
|
46
|
-
|
|
47
|
-
if [ -z "$NODE" ]; then
|
|
48
|
-
echo "mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE." >&2
|
|
49
|
-
exit 1
|
|
50
|
-
fi
|
|
51
|
-
|
|
52
|
-
exec "$NODE" "$ENTRY" mcp
|