mellos-mapping 0.20.0 → 0.20.2

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 (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -2,35 +2,153 @@
2
2
  * Layer 1b — Node-side persistence for a MellosMap.
3
3
  *
4
4
  * The state file IS the event bus of the whole plugin: the MCP server writes
5
- * it, the terminal watcher polls it. The file FORMAT (version, page-id
5
+ * it, the terminal watcher polls it — and the watcher reports back what it is
6
+ * showing (see viewers below), which is how a writer can tell whether anybody
7
+ * is actually SEEING the map it is updating. The file FORMAT (version, page-id
6
8
  * grammar, parse/serialize with boundary validation) lives in ./format.ts,
7
9
  * pure of I/O so browsers can consume it; this module owns everything that
8
10
  * touches the filesystem, and one promise:
9
11
  *
10
12
  * P2. Writes are atomic: a reader polling the file either sees the previous
11
13
  * complete map or the new complete map, never a torn write. Achieved by
12
- * writing a sibling temp file and renaming it over the target.
14
+ * writing a PRIVATE sibling temp file and renaming it over the target.
13
15
  *
14
- * Expected failures (missing file, malformed JSON, invariant violations) are
15
- * Result values. Only truly unexpected I/O faults (permissions, disk) are
16
- * allowed to propagate as exceptions.
16
+ * The concurrency model P2 buys, stated plainly:
17
+ * - Several writers may target one project at once. Each save is atomic and
18
+ * lands whole, so a reader never sees half a map — but there is NO
19
+ * lost-update protection: two saves of the same page race, and the last
20
+ * rename wins, silently discarding what the other writer computed from an
21
+ * older read. Pages are the isolation unit (one effort = one page); two
22
+ * sessions that must not clobber each other belong on two pages.
23
+ * - The temp file carries the writer's pid and a random suffix, so
24
+ * concurrent writers never share one and never install each other's
25
+ * half-written content.
26
+ * - A rename can transiently fail while a reader holds the target open
27
+ * (EPERM/EBUSY on Windows), so it is retried with a short backoff before
28
+ * the save is reported as failed.
29
+ * - A page can also be DELETED (deletePageFile). Deletion races a writer
30
+ * the same way a save does, and the WRITER WINS: a save landing after it
31
+ * recreates the page. Stated at the function, not defended against.
32
+ *
33
+ * Expected failures (missing file, malformed JSON, invariant violations, a
34
+ * write that would not land) are Result values. A failed save changed
35
+ * nothing: the previous file content is intact and the caller may retry.
36
+ * Only truly unexpected I/O faults on the READ path are allowed to propagate
37
+ * as exceptions.
17
38
  *
18
39
  * Node consumers import everything from here; the format surface is
19
40
  * re-exported so persistence has one import site per runtime.
20
41
  */
21
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
42
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
22
43
  import { basename, dirname, join } from 'node:path';
23
44
  import { err, ok } from '../domain/types.js';
24
45
  import { makePageId, parseMap, serializeMap } from './format.js';
25
46
  export { STATE_FILE_VERSION, makePageId, describeStoreError, parseMap, serializeMap, } from './format.js';
47
+ // ---------------------------------------------------------------------------
48
+ // atomic writes — the one primitive every save in this module is built on
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * How many times a rename is attempted before the save is reported failed.
52
+ * A reader's open handle blocks a rename on Windows for as long as it holds
53
+ * the file; the watcher reads a page in well under a tick, so a handful of
54
+ * attempts spans far more than any legitimate reader needs.
55
+ */
56
+ const RENAME_MAX_ATTEMPTS = 10;
57
+ /** Backoff granularity: attempt N waits N * this, so ten attempts span ~450ms. */
58
+ const RENAME_BACKOFF_STEP_MS = 10;
26
59
  /**
27
- * Project-relative location of the DEFAULT page's state file. The store lives
28
- * in the tool-owned `.mellos/` directory: the map belongs to mellos-mapping,
29
- * not to whichever host (Claude Code, Codex, a harness) happens to drive the
30
- * server, so no host brand appears in the path. Pre-0.19 stores under
31
- * `.claude/` are moved once by {@link migrateLegacyStore}.
60
+ * errno codes a rename can raise while the target is momentarily unavailable
61
+ * — a reader holding it open (EPERM/EBUSY/EACCES on Windows) or an
62
+ * antivirus/indexer briefly owning it (ENOENT between its own operations).
63
+ * Anything else (ENOSPC, EROFS, ENOTDIR) is a real fault: retrying it only
64
+ * delays the report.
32
65
  */
33
- export const STATE_FILE_RELATIVE_PATH = join('.mellos', 'map.json');
66
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOENT']);
67
+ /**
68
+ * Block this thread for `ms`. The save path is synchronous by contract (the
69
+ * MCP tool answers after the file is on disk), so the backoff must be too.
70
+ */
71
+ function sleepSync(ms) {
72
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
73
+ }
74
+ /** The failed write's leftover temp, removed on a best-effort basis. */
75
+ function discardTemp(tmp) {
76
+ try {
77
+ rmSync(tmp, { force: true });
78
+ }
79
+ catch {
80
+ // The temp is unreachable for the same reason the write failed; leaving
81
+ // a stray *.tmp is strictly better than masking the original refusal.
82
+ }
83
+ }
84
+ function errnoOf(e) {
85
+ return e.code ?? e.message;
86
+ }
87
+ /**
88
+ * Write `contents` to `path` atomically (P2).
89
+ *
90
+ * Preconditions: none — the parent directory is created if missing.
91
+ * Postcondition on ok: `path` holds exactly `contents` and no temp file
92
+ * remains. Postcondition on error: `path` is untouched (it keeps its
93
+ * previous content, or stays absent) and no temp file remains.
94
+ *
95
+ * The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
96
+ * two writers racing on one page cannot install each other's partial content
97
+ * or make each other's rename miss its file.
98
+ */
99
+ function writeFileAtomic(path, contents) {
100
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
101
+ try {
102
+ mkdirSync(dirname(path), { recursive: true });
103
+ writeFileSync(tmp, contents, 'utf8');
104
+ }
105
+ catch (e) {
106
+ discardTemp(tmp);
107
+ return err({ kind: 'save-failed', path, detail: `writing the temp file failed: ${errnoOf(e)}` });
108
+ }
109
+ let attempt = 1;
110
+ for (;;) {
111
+ try {
112
+ renameSync(tmp, path);
113
+ return ok(undefined);
114
+ }
115
+ catch (e) {
116
+ const code = errnoOf(e);
117
+ if (!TRANSIENT_RENAME_CODES.has(code) || attempt >= RENAME_MAX_ATTEMPTS) {
118
+ discardTemp(tmp);
119
+ return err({ kind: 'save-failed', path, detail: `${code} after ${attempt} attempt(s)` });
120
+ }
121
+ sleepSync(attempt * RENAME_BACKOFF_STEP_MS);
122
+ attempt += 1;
123
+ }
124
+ }
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // reading text that a human may have touched — shared by every load below
128
+ // ---------------------------------------------------------------------------
129
+ function isRecord(v) {
130
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
131
+ }
132
+ /**
133
+ * Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
134
+ * PowerShell redirections) add one when a human edits a state file by hand,
135
+ * and JSON.parse refuses the result — an invisible character would otherwise
136
+ * read as "your map is corrupt". The BOM carries no meaning for us: the
137
+ * files are UTF-8 by contract.
138
+ */
139
+ function stripBom(text) {
140
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
141
+ }
142
+ /**
143
+ * The directory a store lives in, under a project root or under a user's home.
144
+ * It is tool-owned: the map belongs to mellos-mapping, not to whichever host
145
+ * (Claude Code, Codex, a harness) happens to drive the server, so no host
146
+ * brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
147
+ * by {@link migrateLegacyStore}.
148
+ */
149
+ export const STORE_DIR_NAME = '.mellos';
150
+ /** Project-relative location of the DEFAULT page's state file. */
151
+ export const STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, 'map.json');
34
152
  // ---------------------------------------------------------------------------
35
153
  // pages — a project may keep several maps side by side (one effort = one page)
36
154
  // ---------------------------------------------------------------------------
@@ -70,6 +188,42 @@ export function listPageFiles(defaultFile) {
70
188
  }
71
189
  return out;
72
190
  }
191
+ /**
192
+ * Delete one page's file — a named page, or the DEFAULT page (whose file is
193
+ * optional by design, so removing it is a legal state, not a mutilation).
194
+ *
195
+ * Preconditions: none. Postcondition on ok: no file at `path` — an already
196
+ * absent one is ok too, because the goal state is what is promised, not the
197
+ * act. Postcondition on error: the file is still there and the caller may
198
+ * retry or report; the errno is carried in the detail.
199
+ *
200
+ * Concurrency, stated plainly: deletion races a concurrent writer and THE
201
+ * WRITER WINS. A server saving that page while this runs simply recreates the
202
+ * file (its rename is atomic and needs no existing target), so the page comes
203
+ * back. That is accepted rather than defended against — the store has no
204
+ * lost-update protection anywhere (see the module header), and locking one
205
+ * operation would only make the race rarer, never absent, while claiming
206
+ * otherwise. Pages are the isolation unit: nobody deletes a page another
207
+ * session is writing.
208
+ *
209
+ * What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
210
+ * siblings. Those temps are private to a save IN FLIGHT, and a live writer
211
+ * whose temp vanished would fail its rename — turning a harmless leftover
212
+ * into a broken save. A stray temp only exists when a write failed AND its
213
+ * own cleanup failed; it is inert, and the README documents it.
214
+ */
215
+ export function deletePageFile(path) {
216
+ try {
217
+ // force: an absent file is the goal state already, not a failure.
218
+ // No `recursive`: a DIRECTORY where a page file belongs is a fault to
219
+ // report, never a tree to erase.
220
+ rmSync(path, { force: true });
221
+ return ok(undefined);
222
+ }
223
+ catch (e) {
224
+ return err({ kind: 'delete-failed', path, detail: errnoOf(e) });
225
+ }
226
+ }
73
227
  // ---------------------------------------------------------------------------
74
228
  // focus requests — "show this page" messages from pane openers to the watcher
75
229
  // ---------------------------------------------------------------------------
@@ -124,19 +278,290 @@ export function takeFocusRequest(defaultFile) {
124
278
  return id.ok ? { page: id.value } : undefined;
125
279
  }
126
280
  // ---------------------------------------------------------------------------
281
+ // quit requests — "close yourself" messages from the toggle to the watcher
282
+ // ---------------------------------------------------------------------------
283
+ //
284
+ // The mirror of the focus file, and there for the same reason: a human who
285
+ // types `mmap` in some OTHER terminal has no channel to the pane that is
286
+ // already running. The quit file is that channel, one-shot on purpose — the
287
+ // watcher consumes the request AND DELETES the file, so a request lives about
288
+ // one poll tick and nothing stale survives to close tomorrow's pane.
289
+ //
290
+ // The request carries no payload. A pane belongs to one store, so "close the
291
+ // pane watching this store" has nothing to say beyond being asked.
292
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
293
+ export const QUIT_FILE_NAME = 'quit';
294
+ export function quitFilePath(defaultFile) {
295
+ return join(dirname(defaultFile), QUIT_FILE_NAME);
296
+ }
297
+ /**
298
+ * Consume a pending quit request: read it, delete the file, say whether there
299
+ * was one. Absent file — the overwhelmingly common case — or content that is
300
+ * not a JSON object means NO request; the channel is best-effort and junk is
301
+ * swept by the same delete.
302
+ *
303
+ * The empty JSON object is the whole grammar. It exists so that a stray file
304
+ * of this name — an editor backup, a half-written write from a foreign tool —
305
+ * cannot take a live pane down by accident; a pane closing is the one thing
306
+ * in this channel a user cannot undo by waiting.
307
+ */
308
+ export function takeQuitRequest(defaultFile) {
309
+ const path = quitFilePath(defaultFile);
310
+ let raw;
311
+ try {
312
+ raw = readFileSync(path, 'utf8');
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ sweepQuitRequest(defaultFile);
318
+ let parsed;
319
+ try {
320
+ parsed = JSON.parse(stripBom(raw));
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ return isRecord(parsed);
326
+ }
327
+ /**
328
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
329
+ * leftover rather than as a message.
330
+ *
331
+ * A toggle that wrote the request and then lost its watcher (a crash, a
332
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
333
+ * open would consume it on its first tick and close instantly. The watcher
334
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
335
+ * have been addressed to it. Best-effort, like every delete in this channel.
336
+ */
337
+ export function sweepQuitRequest(defaultFile) {
338
+ try {
339
+ rmSync(quitFilePath(defaultFile), { force: true });
340
+ }
341
+ catch {
342
+ // The file is unreachable for some reason the next tick will meet again;
343
+ // re-consuming a request we cannot delete only closes a pane the user
344
+ // asked to close.
345
+ }
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // viewers — "somebody is looking at this store" reports, from the panes
349
+ // ---------------------------------------------------------------------------
350
+ //
351
+ // The first channel that runs the OTHER way. focus and quit are messages TO a
352
+ // pane; this is a pane answering the question every other surface had to
353
+ // guess at, and mostly guessed wrong: is anyone actually SEEING this map?
354
+ //
355
+ // The guess was the bug. An assistant would declare a design, light nodes up
356
+ // as the work went, and report all of it into a store nobody had open —
357
+ // because opening the pane took a human typing `mmap`, and nothing in the
358
+ // system could tell that it had not happened. A map nobody can see is not a
359
+ // map; it is a file. So a pane now says it is there, and the launcher, the
360
+ // toggle and the MCP server read the same answer instead of inventing three.
361
+ //
362
+ // One file per pane, named by its pid: two panes on one store (a split and a
363
+ // dedicated window, `--force`) never clobber each other's report, and the
364
+ // name IS the identity, so nothing inside the payload repeats it.
365
+ //
366
+ // FRESHNESS IS THE FILE'S MTIME, and nothing else. A pane rewrites its report
367
+ // every VIEWER_HEARTBEAT_MS, so a report older than VIEWER_STALE_MS belongs
368
+ // to a pane that is gone — killed, closed with its window, or wedged. A
369
+ // timestamp INSIDE the payload would be a second truth about the same fact,
370
+ // free to disagree with the first; a pid checked for liveness would be worse
371
+ // still, because Windows recycles pids and a recycled one would report a
372
+ // phantom pane — exactly the lie this channel exists to end.
373
+ /** Directory (next to the default file) holding one report per live pane. */
374
+ export const VIEWERS_DIR_NAME = 'viewers';
375
+ /** On-disk report format version. Bump only with a documented migration. */
376
+ export const VIEWER_FILE_VERSION = 1;
377
+ /**
378
+ * How often a pane refreshes its report — the pane's timer, and the unit the
379
+ * two thresholds below are counted in.
380
+ */
381
+ export const VIEWER_HEARTBEAT_MS = 1_000;
382
+ /**
383
+ * A report older than this is not a pane, it is a pane's remains. Five missed
384
+ * heartbeats: long enough to survive a garbage collection, a slow repaint or
385
+ * a busy disk, short enough that a closed pane is known closed before anyone
386
+ * asks twice.
387
+ */
388
+ export const VIEWER_STALE_MS = 5_000;
389
+ /**
390
+ * A report this old is deleted on sight by whoever reads it. A pane that was
391
+ * killed leaves its file behind forever, and a file that outlives every pane
392
+ * would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
393
+ * slack: a machine that just woke from sleep has stale reports whose panes
394
+ * are alive and about to beat again, and there is no reason to make them pay
395
+ * for the sleep with a deleted file.
396
+ */
397
+ export const VIEWER_SWEEP_MS = 60_000;
398
+ /** Where a store's viewer reports live, given the default page's file path. */
399
+ export function viewersDirPath(defaultFile) {
400
+ return join(dirname(defaultFile), VIEWERS_DIR_NAME);
401
+ }
402
+ /** Where ONE pane's report lives. The pid in the name is the pane's identity. */
403
+ export function viewerFilePath(defaultFile, pid) {
404
+ return join(viewersDirPath(defaultFile), `${pid}.json`);
405
+ }
406
+ /**
407
+ * The pid a viewer file name denotes, or undefined when the name is not one
408
+ * of ours. Strict on purpose: the directory also holds the `*.tmp` files of
409
+ * saves in flight (writeFileAtomic), and a temp read as a report would be a
410
+ * pane that never existed.
411
+ */
412
+ function viewerPidOf(fileName) {
413
+ const m = /^(\d+)\.json$/.exec(fileName);
414
+ return m === null ? undefined : Number(m[1]);
415
+ }
416
+ /** Read one report's payload; undefined for anything that is not one. */
417
+ function parseViewerReport(raw) {
418
+ let parsed;
419
+ try {
420
+ parsed = JSON.parse(stripBom(raw));
421
+ }
422
+ catch {
423
+ return undefined; // a torn read from a foreign writer, or junk
424
+ }
425
+ if (!isRecord(parsed))
426
+ return undefined;
427
+ if (parsed['version'] !== VIEWER_FILE_VERSION)
428
+ return undefined;
429
+ const follow = parsed['follow'];
430
+ if (typeof follow !== 'boolean')
431
+ return undefined;
432
+ const page = parsed['page'];
433
+ if (page === null || page === undefined)
434
+ return { page: undefined, follow };
435
+ if (typeof page !== 'string')
436
+ return undefined;
437
+ const id = makePageId(page);
438
+ return id.ok ? { page: id.value, follow } : undefined;
439
+ }
440
+ /**
441
+ * Publish this pane's report, atomically (P2) — a reader polling the file
442
+ * sees the previous whole report or the new one, never half of one.
443
+ *
444
+ * Preconditions: none; the viewers directory is created if missing.
445
+ * Postcondition on ok: the pane's file holds `report` and its mtime is now,
446
+ * which is what says the pane is alive. On error nothing about the pane is
447
+ * claimed, and the next heartbeat is the whole recovery — so a caller reports
448
+ * a failed beat at most once and keeps beating.
449
+ */
450
+ export function publishViewer(defaultFile, pid, report) {
451
+ const body = { version: VIEWER_FILE_VERSION, page: report.page ?? null, follow: report.follow };
452
+ return writeFileAtomic(viewerFilePath(defaultFile, pid), `${JSON.stringify(body, null, 2)}\n`);
453
+ }
454
+ /**
455
+ * Take this pane's report back — the pane is going away and says so, rather
456
+ * than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
457
+ *
458
+ * Best-effort by contract: an exit path is the worst place to raise, and a
459
+ * report nobody could delete goes stale on its own within seconds.
460
+ */
461
+ export function retireViewer(defaultFile, pid) {
462
+ try {
463
+ rmSync(viewerFilePath(defaultFile, pid), { force: true });
464
+ }
465
+ catch {
466
+ // The file outlives us by VIEWER_STALE_MS. That is the whole cost.
467
+ }
468
+ }
469
+ /**
470
+ * Every pane currently showing this store, youngest report first.
471
+ *
472
+ * @param nowMs - the caller's clock (Date.now()), passed in so the ageing
473
+ * rules can be tested without waiting for real seconds to pass.
474
+ * @returns the live reports; an EMPTY array means nobody is seeing this map.
475
+ *
476
+ * Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
477
+ * pane that would have refreshed it is provably gone and no other code runs
478
+ * often enough to notice. A report between stale and sweep is ignored but
479
+ * kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
480
+ * report is: a viewer started by hand counts exactly like one the launcher
481
+ * opened, because the user can see both.
482
+ */
483
+ export function readLiveViewers(defaultFile, nowMs) {
484
+ const dir = viewersDirPath(defaultFile);
485
+ let names;
486
+ try {
487
+ names = readdirSync(dir);
488
+ }
489
+ catch {
490
+ return []; // no directory — no pane has ever run here, the common case
491
+ }
492
+ const live = [];
493
+ for (const name of names) {
494
+ const pid = viewerPidOf(name);
495
+ if (pid === undefined)
496
+ continue;
497
+ const path = join(dir, name);
498
+ let raw;
499
+ let ageMs;
500
+ try {
501
+ // stat BEFORE the read: a report that goes stale between the two calls
502
+ // is merely counted a millisecond young, while the reverse order would
503
+ // age every report by however long its read took.
504
+ ageMs = Math.max(0, nowMs - statSync(path).mtimeMs);
505
+ if (ageMs > VIEWER_SWEEP_MS) {
506
+ rmSync(path, { force: true });
507
+ continue;
508
+ }
509
+ if (ageMs > VIEWER_STALE_MS)
510
+ continue;
511
+ raw = readFileSync(path, 'utf8');
512
+ }
513
+ catch {
514
+ continue; // vanished or unreadable under us: not a pane we can speak for
515
+ }
516
+ const report = parseViewerReport(raw);
517
+ if (report !== undefined)
518
+ live.push({ ...report, pid, ageMs });
519
+ }
520
+ return live.sort((a, b) => a.ageMs - b.ageMs || a.pid - b.pid);
521
+ }
522
+ // ---------------------------------------------------------------------------
127
523
  // mapping policy — WHEN the assistant should open a map, chosen by the user
128
524
  // ---------------------------------------------------------------------------
129
525
  //
130
526
  // Plugin configuration, not map data: it never enters a MellosMap and the
131
527
  // ledger never enforces it (the ledger is not a judge). It lives in its own
132
- // sibling file so hand-editing or corrupting it can never touch a map.
133
- /** Sibling of the default file holding the project's plugin configuration. */
528
+ // file so hand-editing or corrupting it can never touch a map.
529
+ //
530
+ // TWO SCOPES, one file format:
531
+ //
532
+ // user <home>/.mellos/config.json — the normal one. The question "when
533
+ // should maps open?" is about how somebody works, not about a
534
+ // particular repository, so it is asked ONCE, right after install,
535
+ // and answered for every project they will ever open.
536
+ // project <root>/.mellos/config.json — the override. A project that needs a
537
+ // different answer from its owner's habit says so, and wins.
538
+ //
539
+ // PROJECT beats USER wherever both are set (effectiveMappingPolicy); neither
540
+ // set means nobody has chosen yet, which is the one state that still prompts.
541
+ // Both are read and written by the same pair of functions, which take the
542
+ // CONFIG FILE PATH — not a map path — precisely so neither scope can grow a
543
+ // loader of its own.
544
+ /** Name of the file holding a mapping-policy configuration, in either scope. */
134
545
  export const CONFIG_FILE_NAME = 'config.json';
135
546
  /** On-disk config format version. Bump only with a documented migration. */
136
547
  export const CONFIG_FILE_VERSION = 1;
548
+ /** The PROJECT-scope configuration file: sibling of the default page. */
137
549
  export function configFilePath(defaultFile) {
138
550
  return join(dirname(defaultFile), CONFIG_FILE_NAME);
139
551
  }
552
+ /**
553
+ * The USER-scope configuration file: the same store directory name, under the
554
+ * user's own base directory.
555
+ *
556
+ * @param userBase - the user's home directory. Always passed in, never read
557
+ * from the environment down here: a function that reached for os.homedir()
558
+ * itself would make every spec a gamble on the developer's real
559
+ * configuration, and one of them would eventually write it. Entry points
560
+ * resolve the home once and hand it down.
561
+ */
562
+ export function userConfigFilePath(userBase) {
563
+ return join(userBase, STORE_DIR_NAME, CONFIG_FILE_NAME);
564
+ }
140
565
  export const MAPPING_POLICIES = ['always', 'complex', 'on-request'];
141
566
  export function makeMappingPolicy(raw) {
142
567
  return MAPPING_POLICIES.includes(raw)
@@ -154,16 +579,15 @@ export function describeMappingPolicy(policy) {
154
579
  return 'map only when the user explicitly asks';
155
580
  }
156
581
  }
157
- function isRecord(v) {
158
- return typeof v === 'object' && v !== null && !Array.isArray(v);
159
- }
160
582
  /**
161
- * The configured policy, or ok(undefined) when the project has never been
162
- * set up (missing file or missing key — both mean "nobody chose yet").
583
+ * The policy recorded in ONE configuration file, or ok(undefined) when nobody
584
+ * has chosen there (missing file or missing key — both mean the same thing).
163
585
  * A file that exists but does not parse is an error, never silently ignored.
586
+ *
587
+ * @param path - the configuration file itself: {@link configFilePath} for a
588
+ * project, {@link userConfigFilePath} for the user. One loader, two scopes.
164
589
  */
165
- export function loadMappingPolicy(defaultFile) {
166
- const path = configFilePath(defaultFile);
590
+ export function loadMappingPolicy(path) {
167
591
  let text;
168
592
  try {
169
593
  text = readFileSync(path, 'utf8');
@@ -175,7 +599,7 @@ export function loadMappingPolicy(defaultFile) {
175
599
  }
176
600
  let raw;
177
601
  try {
178
- raw = JSON.parse(text);
602
+ raw = JSON.parse(stripBom(text));
179
603
  }
180
604
  catch (e) {
181
605
  return err({ kind: 'malformed-json', path, detail: e.message });
@@ -195,19 +619,49 @@ export function loadMappingPolicy(defaultFile) {
195
619
  ? ok(policy.value)
196
620
  : err({ kind: 'bad-shape', path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(' | ')}` });
197
621
  }
198
- /** Persist the policy atomically (P2), same temp-and-rename as the map files. */
199
- export function saveMappingPolicy(defaultFile, policy) {
200
- const path = configFilePath(defaultFile);
201
- mkdirSync(dirname(path), { recursive: true });
202
- const tmp = path + '.tmp';
203
- writeFileSync(tmp, JSON.stringify({ version: CONFIG_FILE_VERSION, policy }, null, 2) + '\n', 'utf8');
204
- renameSync(tmp, path);
622
+ /**
623
+ * Persist the policy atomically (P2), same write as the map files.
624
+ * @param path - the configuration file to write; see {@link loadMappingPolicy}.
625
+ * @returns ok when the file holds the policy; save-failed leaves the previous
626
+ * configuration in place.
627
+ */
628
+ export function saveMappingPolicy(path, policy) {
629
+ const body = JSON.stringify({ version: CONFIG_FILE_VERSION, policy }, null, 2) + '\n';
630
+ return writeFileAtomic(path, body);
631
+ }
632
+ /** The two places a mapping policy can be recorded, in override order. */
633
+ export const POLICY_SCOPES = ['user', 'project'];
634
+ /**
635
+ * Resolve the policy that governs this project: the PROJECT file if it names
636
+ * one, otherwise the USER file, otherwise nothing.
637
+ *
638
+ * Both scopes are reported, not just the winner — a surface that says "always"
639
+ * without saying where it came from cannot tell a user why changing their
640
+ * user-level choice did nothing here.
641
+ *
642
+ * @param projectConfigFile - see {@link configFilePath}.
643
+ * @param userConfigFile - see {@link userConfigFilePath}.
644
+ * @returns err as soon as EITHER file exists and is broken, project first: a
645
+ * configuration nobody can read is not the same as a configuration nobody
646
+ * wrote, and silently falling through to the other scope would act on a
647
+ * choice the user did not make.
648
+ */
649
+ export function effectiveMappingPolicy(projectConfigFile, userConfigFile) {
650
+ const project = loadMappingPolicy(projectConfigFile);
651
+ if (!project.ok)
652
+ return project;
653
+ const user = loadMappingPolicy(userConfigFile);
654
+ if (!user.ok)
655
+ return user;
656
+ const effective = project.value ?? user.value;
657
+ const source = project.value !== undefined ? 'project' : user.value !== undefined ? 'user' : undefined;
658
+ return ok({ project: project.value, user: user.value, effective, source });
205
659
  }
206
660
  // ---------------------------------------------------------------------------
207
661
  // legacy migration — stores written under the old host-coupled location
208
662
  // ---------------------------------------------------------------------------
209
663
  //
210
- // Up to 0.18 the store lived in `.claude/mellos-mapping.*`: the map's home
664
+ // Up to 0.19 the store lived in `.claude/mellos-mapping.*`: the map's home
211
665
  // was coupled to one host brand, which turned absurd the moment another host
212
666
  // (a harness, Codex) drove the same server. The move is one-time and
213
667
  // explicit — entry points call it before touching the store; nothing here
@@ -259,7 +713,7 @@ export function loadMapFile(path) {
259
713
  }
260
714
  let raw;
261
715
  try {
262
- raw = JSON.parse(text);
716
+ raw = JSON.parse(stripBom(text));
263
717
  }
264
718
  catch (e) {
265
719
  // Expected at this boundary: hand-edited files, or a reader racing a
@@ -269,13 +723,12 @@ export function loadMapFile(path) {
269
723
  return parseMap(raw, path);
270
724
  }
271
725
  /**
272
- * Write the map to `path` atomically (P2): serialize to `<path>.tmp` in the
273
- * same directory, then rename over the target. Creates the parent directory
274
- * if missing.
726
+ * Write the map to `path` atomically (P2): serialize to a private sibling
727
+ * temp file, then rename it over the target, retrying a rename the OS
728
+ * refuses transiently. Creates the parent directory if missing.
729
+ * @returns ok when the file holds the new map; save-failed when it does not,
730
+ * in which case the previous content is intact and the call may be retried.
275
731
  */
276
732
  export function saveMapFile(path, map) {
277
- mkdirSync(dirname(path), { recursive: true });
278
- const tmp = path + '.tmp';
279
- writeFileSync(tmp, serializeMap(map), 'utf8');
280
- renameSync(tmp, path);
733
+ return writeFileAtomic(path, serializeMap(map));
281
734
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "bin": {
27
27
  "mellos-mapping": "dist/server.mjs",
28
- "mellos-mapping-watch": "dist/watch.mjs"
28
+ "mellos-mapping-watch": "dist/watch.mjs",
29
+ "mmap": "dist/mmap.mjs"
29
30
  },
30
31
  "exports": {
31
32
  "./domain/types": {
@@ -52,6 +53,7 @@
52
53
  "types": "./lib/render/render.d.ts",
53
54
  "default": "./lib/render/render.js"
54
55
  },
56
+ "./server": "./dist/server.mjs",
55
57
  "./package.json": "./package.json"
56
58
  },
57
59
  "publishConfig": {
@@ -62,7 +64,10 @@
62
64
  "lib",
63
65
  "README.zh-CN.md",
64
66
  "scripts/codex-register.mjs",
65
- "scripts/open-pane.mjs"
67
+ "scripts/install-mmap-command.mjs",
68
+ "scripts/mmap.mjs",
69
+ "scripts/open-pane.mjs",
70
+ "scripts/pane-core.mjs"
66
71
  ],
67
72
  "engines": {
68
73
  "node": ">=18"
@@ -70,8 +75,11 @@
70
75
  "scripts": {
71
76
  "test": "vitest run",
72
77
  "typecheck": "tsc --noEmit",
78
+ "typecheck:packages": "tsc -p tsconfig.packages.json",
73
79
  "build": "node build.mjs && tsc -p tsconfig.lib.json",
74
- "verify": "npm run typecheck && npm run test && npm run build"
80
+ "check:package": "node scripts/check-package-surface.mjs",
81
+ "prepack": "npm run build",
82
+ "verify": "npm run typecheck && npm run typecheck:packages && npm run test && npm run build && npm run check:package"
75
83
  },
76
84
  "devDependencies": {
77
85
  "@modelcontextprotocol/sdk": "^1.12.0",