mellos-mapping 0.20.3 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +123 -69
  2. package/README.zh-CN.md +120 -65
  3. package/dist/hook-session-start.mjs +25 -19
  4. package/dist/mmap.mjs +302 -170
  5. package/dist/preview.mjs +1418 -0
  6. package/dist/server.mjs +1197 -399
  7. package/dist/store-paths.mjs +40 -21
  8. package/dist/terminal-worker.mjs +3188 -0
  9. package/dist/watch.mjs +522 -279
  10. package/dist/web/TERMINAL-LICENSES.txt +70 -0
  11. package/dist/web/app.css +967 -0
  12. package/dist/web/app.js +1356 -0
  13. package/dist/web/index.html +9 -0
  14. package/dist/web/terminal.css +9 -0
  15. package/dist/web/terminal.html +7 -0
  16. package/dist/web/terminal.js +9293 -0
  17. package/dist/web/xterm.css +285 -0
  18. package/dist/web.mjs +5535 -0
  19. package/docs/codex.md +183 -0
  20. package/lib/domain/text.d.ts +9 -0
  21. package/lib/domain/text.js +43 -0
  22. package/lib/preview/index.d.ts +3 -0
  23. package/lib/preview/index.js +3 -0
  24. package/lib/preview/markdown.d.ts +8 -0
  25. package/lib/preview/markdown.js +54 -0
  26. package/lib/preview/presentation.d.ts +6 -0
  27. package/lib/preview/presentation.js +14 -0
  28. package/lib/preview/publisher.d.ts +23 -0
  29. package/lib/preview/publisher.js +143 -0
  30. package/lib/preview/svg.d.ts +3 -0
  31. package/lib/preview/svg.js +74 -0
  32. package/lib/preview/text.d.ts +4 -0
  33. package/lib/preview/text.js +13 -0
  34. package/lib/render/canvas.d.ts +1 -1
  35. package/lib/render/canvas.js +4 -2
  36. package/lib/render/draw.js +9 -6
  37. package/lib/render/render.d.ts +6 -0
  38. package/lib/render/render.js +50 -18
  39. package/lib/render/width.js +3 -1
  40. package/lib/store/atomic.d.ts +18 -0
  41. package/lib/store/atomic.js +86 -0
  42. package/lib/store/channels.d.ts +40 -0
  43. package/lib/store/channels.js +135 -0
  44. package/lib/store/format.js +3 -1
  45. package/lib/store/json-text.d.ts +9 -0
  46. package/lib/store/json-text.js +16 -0
  47. package/lib/store/maps.d.ts +12 -0
  48. package/lib/store/maps.js +42 -0
  49. package/lib/store/migration.d.ts +12 -0
  50. package/lib/store/migration.js +46 -0
  51. package/lib/store/pages.d.ts +46 -0
  52. package/lib/store/pages.js +89 -0
  53. package/lib/store/policy.d.ts +74 -0
  54. package/lib/store/policy.js +144 -0
  55. package/lib/store/store.d.ts +9 -256
  56. package/lib/store/store.js +10 -694
  57. package/lib/store/viewers.d.ts +81 -0
  58. package/lib/store/viewers.js +186 -0
  59. package/package.json +25 -5
  60. package/scripts/codex-cli.mjs +42 -0
  61. package/scripts/codex-register.mjs +27 -94
  62. package/scripts/mmap.mjs +26 -16
  63. package/scripts/open-pane.mjs +37 -18
  64. package/scripts/pane-core.mjs +57 -220
  65. package/scripts/terminal-session.mjs +137 -0
  66. package/scripts/tmux-session.mjs +90 -0
  67. package/scripts/watcher-command.mjs +16 -0
@@ -46,6 +46,9 @@ export function drawBands(canvas, columns, rows, wiredWidth, totalWidth) {
46
46
  export function drawBox(canvas, box, opts, neutral, face, focused = false) {
47
47
  const { node, x, y, w } = box;
48
48
  const skin = neutral ? neutralSkin(opts.unicode) : skinFor(face, opts.unicode);
49
+ // Hover must not switch font faces: some terminal fonts draw their bold
50
+ // box/line glyphs at a different offset, making a stationary box jump.
51
+ const borderStyle = focused ? 'focus' : skin.style;
49
52
  // Neutral pages give the glyph slot to the node kind (a bullet when kindless).
50
53
  const slotGlyph = neutral ? neutralGlyph(node, opts.unicode) : glyphFor(face, opts);
51
54
  if (box.borderless) {
@@ -56,18 +59,18 @@ export function drawBox(canvas, box, opts, neutral, face, focused = false) {
56
59
  }
57
60
  const inner = w - 2;
58
61
  const pad = box.pad === 1 ? ' ' : '';
59
- canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
60
- canvas.text(x, y + 1, skin.v, skin.style, focused);
62
+ canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], borderStyle);
63
+ canvas.text(x, y + 1, skin.v, borderStyle);
61
64
  canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
62
- canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
65
+ canvas.text(x + w - 1, y + 1, skin.v, borderStyle);
63
66
  for (let i = 0; i < box.extra.length; i++) {
64
67
  const row = box.extra[i];
65
68
  const yy = y + 2 + i;
66
- canvas.text(x, yy, skin.v, skin.style, focused);
69
+ canvas.text(x, yy, skin.v, borderStyle);
67
70
  canvas.text(x + 1, yy, row.text, row.style);
68
- canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
71
+ canvas.text(x + w - 1, yy, skin.v, borderStyle);
69
72
  }
70
- canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
73
+ canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], borderStyle);
71
74
  }
72
75
  /** Every wire. Those touching the focused node render bright. */
73
76
  export function drawEdges(canvas, edges, rows, opts) {
@@ -86,3 +86,9 @@ export interface WindowedRender {
86
86
  }
87
87
  /** Render only the given viewport of the map, plus the full content extent. */
88
88
  export declare function renderMapWindow(map: MellosMap, opts: RenderOptions, viewport: Viewport): WindowedRender;
89
+ /**
90
+ * A pane-owned renderer for immutable map snapshots. Retains only the last
91
+ * scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
92
+ * only repaint it. There is no global cache or filesystem dependency.
93
+ */
94
+ export declare function createWindowRenderer(): typeof renderMapWindow;
@@ -68,12 +68,38 @@ export { statusSgr } from './skins.js';
68
68
  export { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, zoomLabel, } from '../semantics/semantics.js';
69
69
  /** Render the whole map as terminal lines. */
70
70
  export function renderMap(map, opts) {
71
- const built = buildCanvas(map, opts);
71
+ const built = paint(prepareScene(map, opts), opts);
72
72
  return built.canvas.emit(opts);
73
73
  }
74
74
  /** Render only the given viewport of the map, plus the full content extent. */
75
75
  export function renderMapWindow(map, opts, viewport) {
76
- const built = buildCanvas(map, opts);
76
+ return renderSceneWindow(prepareScene(map, opts), opts, viewport);
77
+ }
78
+ /**
79
+ * A pane-owned renderer for immutable map snapshots. Retains only the last
80
+ * scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
81
+ * only repaint it. There is no global cache or filesystem dependency.
82
+ */
83
+ export function createWindowRenderer() {
84
+ let previous;
85
+ let frame;
86
+ return (map, opts, viewport) => {
87
+ const zoom = opts.zoom ?? ZOOM_DEFAULT;
88
+ if (previous?.map !== map || previous.unicode !== opts.unicode || previous.zoom !== zoom) {
89
+ // Commit the cache only after preparation succeeds, so failures retry.
90
+ previous = { map, unicode: opts.unicode, zoom, scene: prepareScene(map, opts) };
91
+ }
92
+ const scene = previous.scene;
93
+ if (frame?.scene !== scene || frame.spinner !== opts.spinnerFrame || frame.focus !== opts.focus || frame.color !== opts.color) {
94
+ frame = { scene, spinner: opts.spinnerFrame, focus: opts.focus, color: opts.color, built: paint(scene, opts) };
95
+ }
96
+ return emitWindow(frame.built, opts, viewport);
97
+ };
98
+ }
99
+ function renderSceneWindow(scene, opts, viewport) {
100
+ return emitWindow(paint(scene, opts), opts, viewport);
101
+ }
102
+ function emitWindow(built, opts, viewport) {
77
103
  return {
78
104
  lines: built.canvas.emit(opts, viewport),
79
105
  contentWidth: built.canvas.width,
@@ -81,22 +107,20 @@ export function renderMapWindow(map, opts, viewport) {
81
107
  hits: built.hits,
82
108
  };
83
109
  }
84
- function buildCanvas(map, opts) {
110
+ /** Static decisions, independent of spinner frame, focus, color and viewport. */
111
+ function prepareScene(map, opts) {
85
112
  const oriented = flipForSequence(map);
86
113
  const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
87
114
  // The far zoom does not shrink the map, it AGGREGATES it: groups become one
88
115
  // box each. Which map is drawn is decided here, once.
89
116
  const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
90
117
  const drawn = aggregated ?? oriented;
91
- return paint(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
118
+ const unverified = unverifiedDoneIds(oriented, drawn);
119
+ if (drawn.layers.length === 0)
120
+ return { map: drawn, unverified };
121
+ return { map: drawn, unverified, geometry: prepareGeometry(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo) };
92
122
  }
93
- function paint(map, opts, geo, unverified) {
94
- const canvas = new Canvas();
95
- if (map.layers.length === 0) {
96
- canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
97
- canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
98
- return { canvas, hits: [] };
99
- }
123
+ function prepareGeometry(map, opts, geo) {
100
124
  const neutral = isNeutralKind(map);
101
125
  const columns = layoutColumns(map, geo, opts.unicode, neutral);
102
126
  const routing = routeEdges(map, columns);
@@ -105,6 +129,21 @@ function paint(map, opts, geo, unverified) {
105
129
  const wiredWidth = routing.fallbackCount > 0 ? columns.contentWidth + 2 + routing.fallbackCount * 2 : columns.contentWidth;
106
130
  /** Plus the band labels' own right margin, which nothing else may enter. */
107
131
  const totalWidth = wiredWidth + Math.max(...columns.bandLabel.map(displayWidth));
132
+ const hits = [...rows.boxOf.values()].map((b) => ({
133
+ id: b.node.id, x: b.x, y: b.y, w: b.w, h: b.h,
134
+ }));
135
+ return { neutral, columns, routing, rows, wiredWidth, totalWidth, hits };
136
+ }
137
+ /** Dynamic drawing always gets a fresh canvas; frames cannot contaminate each other. */
138
+ function paint(scene, opts) {
139
+ const { map, unverified, geometry } = scene;
140
+ const canvas = new Canvas();
141
+ if (geometry === undefined) {
142
+ canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
143
+ canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
144
+ return { canvas, hits: [] };
145
+ }
146
+ const { neutral, columns, routing, rows, wiredWidth, totalWidth, hits } = geometry;
108
147
  if (map.title !== undefined)
109
148
  drawTitle(canvas, map.title);
110
149
  drawLaneHeaders(canvas, map, columns, rows);
@@ -117,12 +156,5 @@ function paint(map, opts, geo, unverified) {
117
156
  }
118
157
  drawEdges(canvas, routing.edges, rows, opts);
119
158
  drawLegend(canvas, map, opts, rows.legendY, neutral, unverified.size > 0);
120
- const hits = [...rows.boxOf.values()].map((b) => ({
121
- id: b.node.id,
122
- x: b.x,
123
- y: b.y,
124
- w: b.w,
125
- h: b.h,
126
- }));
127
159
  return { canvas, hits };
128
160
  }
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * Pure functions of a string; no canvas, no options, no I/O.
16
16
  */
17
+ import { terminalText } from '../domain/text.js';
17
18
  const WIDE_RANGES = [
18
19
  [0x1100, 0x115f], // Hangul Jamo
19
20
  // Wide symbols scattered through the BMP — mostly emoji that predate the
@@ -99,6 +100,7 @@ export function displayWidth(text) {
99
100
  }
100
101
  /** Truncate to a display width, ANSI-free input, appending … when cut. */
101
102
  export function fitWidth(s, width) {
103
+ s = terminalText(s);
102
104
  if (displayWidth(s) <= width)
103
105
  return s;
104
106
  let out = '';
@@ -117,7 +119,7 @@ export function wrapWidth(s, width) {
117
119
  const lines = [];
118
120
  let line = '';
119
121
  let w = 0;
120
- for (const ch of s.replace(/\r/g, '')) {
122
+ for (const ch of terminalText(s.replace(/\r/g, '').replace(/\t/g, ' '), true)) {
121
123
  if (ch === '\n') {
122
124
  lines.push(line);
123
125
  line = '';
@@ -0,0 +1,18 @@
1
+ import { type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ export declare function errnoOf(e: unknown): string;
4
+ /**
5
+ * Write `contents` to `path` atomically (P2).
6
+ *
7
+ * Preconditions: none — the parent directory is created if missing.
8
+ * Postcondition on ok: `path` holds exactly `contents` and no temp file
9
+ * remains. Postcondition on error: `path` is untouched (it keeps its
10
+ * previous content, or stays absent) and no temp file remains.
11
+ *
12
+ * The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
13
+ * two writers racing on one page cannot install each other's partial content
14
+ * or make each other's rename miss its file.
15
+ */
16
+ export declare function writeFileAtomic(path: string, contents: string): Result<void, StoreError>;
17
+ /** A heartbeat may yield to the next tick; an authoritative save must retry. */
18
+ export declare function writeAtomic(path: string, contents: string, maxAttempts: number): Result<void, StoreError>;
@@ -0,0 +1,86 @@
1
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { err, ok } from '../domain/types.js';
4
+ // ---------------------------------------------------------------------------
5
+ // atomic writes — the one primitive every save in this module is built on
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * How many times a rename is attempted before the save is reported failed.
9
+ * A reader's open handle blocks a rename on Windows for as long as it holds
10
+ * the file; the watcher reads a page in well under a tick, so a handful of
11
+ * attempts spans far more than any legitimate reader needs.
12
+ */
13
+ const RENAME_MAX_ATTEMPTS = 10;
14
+ /** Backoff granularity: attempt N waits N * this, so ten attempts span ~450ms. */
15
+ const RENAME_BACKOFF_STEP_MS = 10;
16
+ /**
17
+ * errno codes a rename can raise while the target is momentarily unavailable
18
+ * — a reader holding it open (EPERM/EBUSY/EACCES on Windows) or an
19
+ * antivirus/indexer briefly owning it (ENOENT between its own operations).
20
+ * Anything else (ENOSPC, EROFS, ENOTDIR) is a real fault: retrying it only
21
+ * delays the report.
22
+ */
23
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOENT']);
24
+ /**
25
+ * Block this thread for `ms`. The save path is synchronous by contract (the
26
+ * MCP tool answers after the file is on disk), so the backoff must be too.
27
+ */
28
+ function sleepSync(ms) {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ }
31
+ /** The failed write's leftover temp, removed on a best-effort basis. */
32
+ function discardTemp(tmp) {
33
+ try {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ catch {
37
+ // The temp is unreachable for the same reason the write failed; leaving
38
+ // a stray *.tmp is strictly better than masking the original refusal.
39
+ }
40
+ }
41
+ export function errnoOf(e) {
42
+ return e.code ?? e.message;
43
+ }
44
+ /**
45
+ * Write `contents` to `path` atomically (P2).
46
+ *
47
+ * Preconditions: none — the parent directory is created if missing.
48
+ * Postcondition on ok: `path` holds exactly `contents` and no temp file
49
+ * remains. Postcondition on error: `path` is untouched (it keeps its
50
+ * previous content, or stays absent) and no temp file remains.
51
+ *
52
+ * The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
53
+ * two writers racing on one page cannot install each other's partial content
54
+ * or make each other's rename miss its file.
55
+ */
56
+ export function writeFileAtomic(path, contents) {
57
+ return writeAtomic(path, contents, RENAME_MAX_ATTEMPTS);
58
+ }
59
+ /** A heartbeat may yield to the next tick; an authoritative save must retry. */
60
+ export function writeAtomic(path, contents, maxAttempts) {
61
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
62
+ try {
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ writeFileSync(tmp, contents, 'utf8');
65
+ }
66
+ catch (e) {
67
+ discardTemp(tmp);
68
+ return err({ kind: 'save-failed', path, detail: `writing the temp file failed: ${errnoOf(e)}` });
69
+ }
70
+ let attempt = 1;
71
+ for (;;) {
72
+ try {
73
+ renameSync(tmp, path);
74
+ return ok(undefined);
75
+ }
76
+ catch (e) {
77
+ const code = errnoOf(e);
78
+ if (!TRANSIENT_RENAME_CODES.has(code) || attempt >= maxAttempts) {
79
+ discardTemp(tmp);
80
+ return err({ kind: 'save-failed', path, detail: `${code} after ${attempt} attempt(s)` });
81
+ }
82
+ sleepSync(attempt * RENAME_BACKOFF_STEP_MS);
83
+ attempt += 1;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ import { type PageId } from './format.js';
2
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
3
+ export declare const FOCUS_FILE_NAME = "focus";
4
+ export declare function focusFilePath(defaultFile: string, pid?: number): string;
5
+ /** A consumed focus request: the page to show (undefined = the default page). */
6
+ export interface FocusRequest {
7
+ readonly page: PageId | undefined;
8
+ }
9
+ /**
10
+ * Consume a pending focus request: read it, delete the file, return it.
11
+ * Absent file — the overwhelmingly common case — or junk content means no
12
+ * request; the channel is best-effort and junk is swept by the same delete.
13
+ */
14
+ export declare function takeFocusRequest(defaultFile: string, pid?: number): FocusRequest | undefined;
15
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
16
+ export declare const QUIT_FILE_NAME = "quit";
17
+ export declare function quitFilePath(defaultFile: string, pid?: number): string;
18
+ /**
19
+ * Consume a pending quit request: read it, delete the file, say whether there
20
+ * was one. Absent file — the overwhelmingly common case — or content that is
21
+ * not a JSON object means NO request; the channel is best-effort and junk is
22
+ * swept by the same delete.
23
+ *
24
+ * The empty JSON object is the whole grammar. It exists so that a stray file
25
+ * of this name — an editor backup, a half-written write from a foreign tool —
26
+ * cannot take a live pane down by accident; a pane closing is the one thing
27
+ * in this channel a user cannot undo by waiting.
28
+ */
29
+ export declare function takeQuitRequest(defaultFile: string, pid?: number): boolean;
30
+ /**
31
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
32
+ * leftover rather than as a message.
33
+ *
34
+ * A toggle that wrote the request and then lost its watcher (a crash, a
35
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
36
+ * open would consume it on its first tick and close instantly. The watcher
37
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
38
+ * have been addressed to it. Best-effort, like every delete in this channel.
39
+ */
40
+ export declare function sweepQuitRequest(defaultFile: string, pid?: number): void;
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { makePageId } from './format.js';
4
+ import { isRecord, stripBom } from './json-text.js';
5
+ import { viewersDirPath } from './viewers.js';
6
+ // ---------------------------------------------------------------------------
7
+ // focus requests — "show this page" messages from pane openers to the watcher
8
+ // ---------------------------------------------------------------------------
9
+ //
10
+ // State files flow one way, MCP server → watcher; a launcher that wants an
11
+ // ALREADY-RUNNING pane to show a particular page has no channel to it. The
12
+ // focus file is that channel, one-shot on purpose: the watcher consumes the
13
+ // request AND DELETES the file, so a request lives about one poll tick —
14
+ // nothing stale survives to misdirect tomorrow's pane, and the project's git
15
+ // status barely ever sees the file exist.
16
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
17
+ export const FOCUS_FILE_NAME = 'focus';
18
+ export function focusFilePath(defaultFile, pid) {
19
+ return paneChannelPath(defaultFile, FOCUS_FILE_NAME, pid);
20
+ }
21
+ /** A targeted message cannot be consumed by another pane of the same project. */
22
+ function paneChannelPath(defaultFile, channel, pid) {
23
+ if (pid === undefined)
24
+ return join(dirname(defaultFile), channel);
25
+ if (!Number.isSafeInteger(pid) || pid <= 0)
26
+ throw new Error('Invalid pane process id');
27
+ return join(viewersDirPath(defaultFile), `${pid}.${channel}`);
28
+ }
29
+ /**
30
+ * Consume a pending focus request: read it, delete the file, return it.
31
+ * Absent file — the overwhelmingly common case — or junk content means no
32
+ * request; the channel is best-effort and junk is swept by the same delete.
33
+ */
34
+ export function takeFocusRequest(defaultFile, pid) {
35
+ const targeted = pid === undefined ? undefined : focusFilePath(defaultFile, pid);
36
+ const path = targeted !== undefined && existsSync(targeted) ? targeted : focusFilePath(defaultFile);
37
+ let raw;
38
+ try {
39
+ raw = readFileSync(path, 'utf8');
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ try {
45
+ rmSync(path, { force: true });
46
+ }
47
+ catch {
48
+ // deletion is a courtesy: re-consuming next tick is harmless because
49
+ // switching to the already-shown page is a no-op
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (typeof parsed !== 'object' || parsed === null)
59
+ return undefined;
60
+ const page = parsed.page;
61
+ if (page === undefined || page === null)
62
+ return { page: undefined };
63
+ if (typeof page !== 'string')
64
+ return undefined;
65
+ const id = makePageId(page);
66
+ return id.ok ? { page: id.value } : undefined;
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // quit requests — "close yourself" messages from the toggle to the watcher
70
+ // ---------------------------------------------------------------------------
71
+ //
72
+ // The mirror of the focus file, and there for the same reason: a human who
73
+ // types `mmap` in some OTHER terminal has no channel to the pane that is
74
+ // already running. The quit file is that channel, one-shot on purpose — the
75
+ // watcher consumes the request AND DELETES the file, so a request lives about
76
+ // one poll tick and nothing stale survives to close tomorrow's pane.
77
+ //
78
+ // The request carries no payload. A pane belongs to one store, so "close the
79
+ // pane watching this store" has nothing to say beyond being asked.
80
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
81
+ export const QUIT_FILE_NAME = 'quit';
82
+ export function quitFilePath(defaultFile, pid) {
83
+ return paneChannelPath(defaultFile, QUIT_FILE_NAME, pid);
84
+ }
85
+ /**
86
+ * Consume a pending quit request: read it, delete the file, say whether there
87
+ * was one. Absent file — the overwhelmingly common case — or content that is
88
+ * not a JSON object means NO request; the channel is best-effort and junk is
89
+ * swept by the same delete.
90
+ *
91
+ * The empty JSON object is the whole grammar. It exists so that a stray file
92
+ * of this name — an editor backup, a half-written write from a foreign tool —
93
+ * cannot take a live pane down by accident; a pane closing is the one thing
94
+ * in this channel a user cannot undo by waiting.
95
+ */
96
+ export function takeQuitRequest(defaultFile, pid) {
97
+ const targeted = pid === undefined ? undefined : quitFilePath(defaultFile, pid);
98
+ const path = targeted !== undefined && existsSync(targeted) ? targeted : quitFilePath(defaultFile);
99
+ let raw;
100
+ try {
101
+ raw = readFileSync(path, 'utf8');
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ sweepQuitRequest(defaultFile, path === targeted ? pid : undefined);
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(stripBom(raw));
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ return isRecord(parsed);
115
+ }
116
+ /**
117
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
118
+ * leftover rather than as a message.
119
+ *
120
+ * A toggle that wrote the request and then lost its watcher (a crash, a
121
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
122
+ * open would consume it on its first tick and close instantly. The watcher
123
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
124
+ * have been addressed to it. Best-effort, like every delete in this channel.
125
+ */
126
+ export function sweepQuitRequest(defaultFile, pid) {
127
+ try {
128
+ rmSync(quitFilePath(defaultFile, pid), { force: true });
129
+ }
130
+ catch {
131
+ // The file is unreachable for some reason the next tick will meet again;
132
+ // re-consuming a request we cannot delete only closes a pane the user
133
+ // asked to close.
134
+ }
135
+ }
@@ -21,6 +21,7 @@
21
21
  * ./store.ts, the Node-side half.
22
22
  */
23
23
  import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
24
+ import { mapTextError } from '../domain/text.js';
24
25
  import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
25
26
  /** On-disk format version. Bump only with a documented migration. */
26
27
  export const STATE_FILE_VERSION = 1;
@@ -316,7 +317,8 @@ export function parseMap(raw, path) {
316
317
  return err({ kind: 'invariant-violation', path, violation: linked.error });
317
318
  map = linked.value;
318
319
  }
319
- return ok(map);
320
+ const textError = mapTextError(map);
321
+ return textError ? err({ kind: 'bad-shape', path, detail: textError }) : ok(map);
320
322
  }
321
323
  /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
322
324
  export function serializeMap(map) {
@@ -0,0 +1,9 @@
1
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
2
+ /**
3
+ * Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
4
+ * PowerShell redirections) add one when a human edits a state file by hand,
5
+ * and JSON.parse refuses the result — an invisible character would otherwise
6
+ * read as "your map is corrupt". The BOM carries no meaning for us: the
7
+ * files are UTF-8 by contract.
8
+ */
9
+ export declare function stripBom(text: string): string;
@@ -0,0 +1,16 @@
1
+ // ---------------------------------------------------------------------------
2
+ // reading text that a human may have touched — shared by every load below
3
+ // ---------------------------------------------------------------------------
4
+ export function isRecord(v) {
5
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ }
7
+ /**
8
+ * Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
9
+ * PowerShell redirections) add one when a human edits a state file by hand,
10
+ * and JSON.parse refuses the result — an invisible character would otherwise
11
+ * read as "your map is corrupt". The BOM carries no meaning for us: the
12
+ * files are UTF-8 by contract.
13
+ */
14
+ export function stripBom(text) {
15
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
16
+ }
@@ -0,0 +1,12 @@
1
+ import { type MellosMap, type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ /** Load and validate the map file at `path`. */
4
+ export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
5
+ /**
6
+ * Write the map to `path` atomically (P2): serialize to a private sibling
7
+ * temp file, then rename it over the target, retrying a rename the OS
8
+ * refuses transiently. Creates the parent directory if missing.
9
+ * @returns ok when the file holds the new map; save-failed when it does not,
10
+ * in which case the previous content is intact and the call may be retried.
11
+ */
12
+ export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { err } from '../domain/types.js';
3
+ import { mapTextError } from '../domain/text.js';
4
+ import { parseMap, serializeMap } from './format.js';
5
+ import { writeFileAtomic } from './atomic.js';
6
+ import { stripBom } from './json-text.js';
7
+ /** Load and validate the map file at `path`. */
8
+ export function loadMapFile(path) {
9
+ let text;
10
+ try {
11
+ text = readFileSync(path, 'utf8');
12
+ }
13
+ catch (e) {
14
+ const code = e.code;
15
+ if (code === 'ENOENT')
16
+ return err({ kind: 'not-found', path });
17
+ throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
18
+ }
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(stripBom(text));
22
+ }
23
+ catch (e) {
24
+ // Expected at this boundary: hand-edited files, or a reader racing a
25
+ // non-atomic writer from a foreign tool.
26
+ return err({ kind: 'malformed-json', path, detail: e.message });
27
+ }
28
+ return parseMap(raw, path);
29
+ }
30
+ /**
31
+ * Write the map to `path` atomically (P2): serialize to a private sibling
32
+ * temp file, then rename it over the target, retrying a rename the OS
33
+ * refuses transiently. Creates the parent directory if missing.
34
+ * @returns ok when the file holds the new map; save-failed when it does not,
35
+ * in which case the previous content is intact and the call may be retried.
36
+ */
37
+ export function saveMapFile(path, map) {
38
+ const textError = mapTextError(map);
39
+ if (textError)
40
+ return err({ kind: 'save-failed', path, detail: textError });
41
+ return writeFileAtomic(path, serializeMap(map));
42
+ }
@@ -0,0 +1,12 @@
1
+ /** Project-relative location of the pre-0.20 default page file. */
2
+ export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
3
+ /**
4
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
5
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
6
+ * store already holds anything keeps it untouched, whatever the legacy
7
+ * directory still contains.
8
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
9
+ * the legacy store is looked up relative to `<root>`.
10
+ * @returns whether a legacy store was moved.
11
+ */
12
+ export declare function migrateLegacyStore(defaultFile: string): boolean;
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { PAGES_DIR_NAME } from './pages.js';
4
+ import { configFilePath } from './policy.js';
5
+ // ---------------------------------------------------------------------------
6
+ // legacy migration — stores written under the old host-coupled location
7
+ // ---------------------------------------------------------------------------
8
+ //
9
+ // Up to 0.19 the store lived in `.claude/mellos-mapping.*`: the map's home
10
+ // was coupled to one host brand, which turned absurd the moment another host
11
+ // (a harness, Codex) drove the same server. The move is one-time and
12
+ // explicit — entry points call it before touching the store; nothing here
13
+ // runs as a hidden side effect of ordinary loads.
14
+ /** Project-relative location of the pre-0.20 default page file. */
15
+ export const LEGACY_STATE_FILE_RELATIVE_PATH = join('.claude', 'mellos-mapping.json');
16
+ const LEGACY_PAGES_DIR_NAME = 'mellos-mapping.pages';
17
+ const LEGACY_CONFIG_FILE_NAME = 'mellos-mapping.config.json';
18
+ /**
19
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
20
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
21
+ * store already holds anything keeps it untouched, whatever the legacy
22
+ * directory still contains.
23
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
24
+ * the legacy store is looked up relative to `<root>`.
25
+ * @returns whether a legacy store was moved.
26
+ */
27
+ export function migrateLegacyStore(defaultFile) {
28
+ const projectRoot = dirname(dirname(defaultFile));
29
+ const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
30
+ const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
31
+ const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
32
+ const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
33
+ const hasCurrent = existsSync(defaultFile)
34
+ || existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
35
+ || existsSync(configFilePath(defaultFile));
36
+ if (!hasLegacy || hasCurrent)
37
+ return false;
38
+ mkdirSync(dirname(defaultFile), { recursive: true });
39
+ if (existsSync(legacyDefault))
40
+ renameSync(legacyDefault, defaultFile);
41
+ if (existsSync(legacyPages))
42
+ renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
43
+ if (existsSync(legacyConfig))
44
+ renameSync(legacyConfig, configFilePath(defaultFile));
45
+ return true;
46
+ }