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.
- package/README.md +123 -69
- package/README.zh-CN.md +120 -65
- package/dist/hook-session-start.mjs +25 -19
- package/dist/mmap.mjs +302 -170
- package/dist/preview.mjs +1418 -0
- package/dist/server.mjs +1197 -399
- package/dist/store-paths.mjs +40 -21
- package/dist/terminal-worker.mjs +3188 -0
- package/dist/watch.mjs +522 -279
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5535 -0
- package/docs/codex.md +183 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +43 -0
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +3 -1
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/store.d.ts +9 -256
- package/lib/store/store.js +10 -694
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +25 -5
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +26 -16
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
package/lib/render/draw.js
CHANGED
|
@@ -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],
|
|
60
|
-
canvas.text(x, y + 1, skin.v,
|
|
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,
|
|
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,
|
|
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,
|
|
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],
|
|
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) {
|
package/lib/render/render.d.ts
CHANGED
|
@@ -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;
|
package/lib/render/render.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/lib/render/width.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/store/format.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|