mellos-mapping 0.20.2 → 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 +125 -88
- package/README.zh-CN.md +122 -84
- package/dist/hook-session-start.mjs +25 -19
- package/dist/mmap.mjs +302 -170
- package/dist/preview.mjs +1418 -0
- package/dist/server.mjs +1198 -400
- package/dist/store-paths.mjs +40 -21
- package/dist/terminal-worker.mjs +3188 -0
- package/dist/watch.mjs +523 -280
- 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/domain/types.js +10 -1
- 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 -6
- 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/store/store.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The state file IS the event bus of the whole plugin: the MCP server writes
|
|
5
5
|
* it, the terminal watcher polls it — and the watcher reports back what it is
|
|
6
|
-
* showing (see viewers below)
|
|
7
|
-
* is
|
|
6
|
+
* showing (see viewers below). These are process reports; terminal visibility
|
|
7
|
+
* is checked separately by the host adapter. The file FORMAT (version, page-id
|
|
8
8
|
* grammar, parse/serialize with boundary validation) lives in ./format.ts,
|
|
9
9
|
* pure of I/O so browsers can consume it; this module owns everything that
|
|
10
10
|
* touches the filesystem, and one promise:
|
|
@@ -39,696 +39,12 @@
|
|
|
39
39
|
* Node consumers import everything from here; the format surface is
|
|
40
40
|
* re-exported so persistence has one import site per runtime.
|
|
41
41
|
*/
|
|
42
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
43
|
-
import { basename, dirname, join } from 'node:path';
|
|
44
|
-
import { err, ok } from '../domain/types.js';
|
|
45
|
-
import { makePageId, parseMap, serializeMap } from './format.js';
|
|
46
42
|
export { STATE_FILE_VERSION, makePageId, describeStoreError, parseMap, serializeMap, } from './format.js';
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
59
|
-
/**
|
|
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.
|
|
65
|
-
*/
|
|
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');
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
// pages — a project may keep several maps side by side (one effort = one page)
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
//
|
|
156
|
-
// The default page IS the classic map.json. Named pages live in a sibling
|
|
157
|
-
// directory, one file each: file-per-page keeps concurrent sessions isolated —
|
|
158
|
-
// two writers on two pages can never clobber each other, because every save
|
|
159
|
-
// renames a whole file.
|
|
160
|
-
/** Directory (next to the default file) holding the named pages. */
|
|
161
|
-
export const PAGES_DIR_NAME = 'pages';
|
|
162
|
-
/** Where a page's map file lives, given the default page's file path. */
|
|
163
|
-
export function pageFilePath(defaultFile, page) {
|
|
164
|
-
return page === undefined ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
|
|
165
|
-
}
|
|
166
|
-
/** The page id a file path denotes; undefined = the default page. */
|
|
167
|
-
export function pageIdOfFile(defaultFile, path) {
|
|
168
|
-
if (path === defaultFile)
|
|
169
|
-
return undefined;
|
|
170
|
-
const name = basename(path);
|
|
171
|
-
return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
|
|
172
|
-
}
|
|
173
|
-
/** Existing page files: the default page first (when present), then named pages sorted by slug. */
|
|
174
|
-
export function listPageFiles(defaultFile) {
|
|
175
|
-
const out = [];
|
|
176
|
-
if (existsSync(defaultFile))
|
|
177
|
-
out.push(defaultFile);
|
|
178
|
-
let entries = [];
|
|
179
|
-
try {
|
|
180
|
-
entries = readdirSync(join(dirname(defaultFile), PAGES_DIR_NAME));
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
// no pages directory — a single-page project, the common case
|
|
184
|
-
}
|
|
185
|
-
for (const e of entries.sort()) {
|
|
186
|
-
if (e.endsWith('.json'))
|
|
187
|
-
out.push(join(dirname(defaultFile), PAGES_DIR_NAME, e));
|
|
188
|
-
}
|
|
189
|
-
return out;
|
|
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
|
-
}
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
// focus requests — "show this page" messages from pane openers to the watcher
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
//
|
|
231
|
-
// State files flow one way, MCP server → watcher; a launcher that wants an
|
|
232
|
-
// ALREADY-RUNNING pane to show a particular page has no channel to it. The
|
|
233
|
-
// focus file is that channel, one-shot on purpose: the watcher consumes the
|
|
234
|
-
// request AND DELETES the file, so a request lives about one poll tick —
|
|
235
|
-
// nothing stale survives to misdirect tomorrow's pane, and the project's git
|
|
236
|
-
// status barely ever sees the file exist.
|
|
237
|
-
/** Sibling of the default file carrying a one-shot "show this page" request. */
|
|
238
|
-
export const FOCUS_FILE_NAME = 'focus';
|
|
239
|
-
export function focusFilePath(defaultFile) {
|
|
240
|
-
return join(dirname(defaultFile), FOCUS_FILE_NAME);
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* Consume a pending focus request: read it, delete the file, return it.
|
|
244
|
-
* Absent file — the overwhelmingly common case — or junk content means no
|
|
245
|
-
* request; the channel is best-effort and junk is swept by the same delete.
|
|
246
|
-
*/
|
|
247
|
-
export function takeFocusRequest(defaultFile) {
|
|
248
|
-
const path = focusFilePath(defaultFile);
|
|
249
|
-
let raw;
|
|
250
|
-
try {
|
|
251
|
-
raw = readFileSync(path, 'utf8');
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return undefined;
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
rmSync(path, { force: true });
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
// deletion is a courtesy: re-consuming next tick is harmless because
|
|
261
|
-
// switching to the already-shown page is a no-op
|
|
262
|
-
}
|
|
263
|
-
let parsed;
|
|
264
|
-
try {
|
|
265
|
-
parsed = JSON.parse(raw);
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
return undefined;
|
|
269
|
-
}
|
|
270
|
-
if (typeof parsed !== 'object' || parsed === null)
|
|
271
|
-
return undefined;
|
|
272
|
-
const page = parsed.page;
|
|
273
|
-
if (page === undefined || page === null)
|
|
274
|
-
return { page: undefined };
|
|
275
|
-
if (typeof page !== 'string')
|
|
276
|
-
return undefined;
|
|
277
|
-
const id = makePageId(page);
|
|
278
|
-
return id.ok ? { page: id.value } : undefined;
|
|
279
|
-
}
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
523
|
-
// mapping policy — WHEN the assistant should open a map, chosen by the user
|
|
524
|
-
// ---------------------------------------------------------------------------
|
|
525
|
-
//
|
|
526
|
-
// Plugin configuration, not map data: it never enters a MellosMap and the
|
|
527
|
-
// ledger never enforces it (the ledger is not a judge). It lives in its own
|
|
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. */
|
|
545
|
-
export const CONFIG_FILE_NAME = 'config.json';
|
|
546
|
-
/** On-disk config format version. Bump only with a documented migration. */
|
|
547
|
-
export const CONFIG_FILE_VERSION = 1;
|
|
548
|
-
/** The PROJECT-scope configuration file: sibling of the default page. */
|
|
549
|
-
export function configFilePath(defaultFile) {
|
|
550
|
-
return join(dirname(defaultFile), CONFIG_FILE_NAME);
|
|
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
|
-
}
|
|
565
|
-
export const MAPPING_POLICIES = ['always', 'complex', 'on-request'];
|
|
566
|
-
export function makeMappingPolicy(raw) {
|
|
567
|
-
return MAPPING_POLICIES.includes(raw)
|
|
568
|
-
? ok(raw)
|
|
569
|
-
: err({ kind: 'invalid-policy', raw, allowed: MAPPING_POLICIES });
|
|
570
|
-
}
|
|
571
|
-
/** One line of meaning per policy — the wording every surface repeats. */
|
|
572
|
-
export function describeMappingPolicy(policy) {
|
|
573
|
-
switch (policy) {
|
|
574
|
-
case 'always':
|
|
575
|
-
return 'map every structured task — workflows, designs, architecture, technical dependencies';
|
|
576
|
-
case 'complex':
|
|
577
|
-
return 'map only medium or complex tasks — several modules, a new subsystem, roughly an hour or more';
|
|
578
|
-
case 'on-request':
|
|
579
|
-
return 'map only when the user explicitly asks';
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
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).
|
|
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.
|
|
589
|
-
*/
|
|
590
|
-
export function loadMappingPolicy(path) {
|
|
591
|
-
let text;
|
|
592
|
-
try {
|
|
593
|
-
text = readFileSync(path, 'utf8');
|
|
594
|
-
}
|
|
595
|
-
catch (e) {
|
|
596
|
-
if (e.code === 'ENOENT')
|
|
597
|
-
return ok(undefined);
|
|
598
|
-
throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
|
|
599
|
-
}
|
|
600
|
-
let raw;
|
|
601
|
-
try {
|
|
602
|
-
raw = JSON.parse(stripBom(text));
|
|
603
|
-
}
|
|
604
|
-
catch (e) {
|
|
605
|
-
return err({ kind: 'malformed-json', path, detail: e.message });
|
|
606
|
-
}
|
|
607
|
-
if (!isRecord(raw))
|
|
608
|
-
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
609
|
-
if (raw['version'] !== CONFIG_FILE_VERSION) {
|
|
610
|
-
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${CONFIG_FILE_VERSION}` });
|
|
611
|
-
}
|
|
612
|
-
const rawPolicy = raw['policy'];
|
|
613
|
-
if (rawPolicy === undefined)
|
|
614
|
-
return ok(undefined);
|
|
615
|
-
if (typeof rawPolicy !== 'string')
|
|
616
|
-
return err({ kind: 'bad-shape', path, detail: 'policy is not a string' });
|
|
617
|
-
const policy = makeMappingPolicy(rawPolicy);
|
|
618
|
-
return policy.ok
|
|
619
|
-
? ok(policy.value)
|
|
620
|
-
: err({ kind: 'bad-shape', path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(' | ')}` });
|
|
621
|
-
}
|
|
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 });
|
|
659
|
-
}
|
|
660
|
-
// ---------------------------------------------------------------------------
|
|
661
|
-
// legacy migration — stores written under the old host-coupled location
|
|
662
|
-
// ---------------------------------------------------------------------------
|
|
663
|
-
//
|
|
664
|
-
// Up to 0.19 the store lived in `.claude/mellos-mapping.*`: the map's home
|
|
665
|
-
// was coupled to one host brand, which turned absurd the moment another host
|
|
666
|
-
// (a harness, Codex) drove the same server. The move is one-time and
|
|
667
|
-
// explicit — entry points call it before touching the store; nothing here
|
|
668
|
-
// runs as a hidden side effect of ordinary loads.
|
|
669
|
-
/** Project-relative location of the pre-0.20 default page file. */
|
|
670
|
-
export const LEGACY_STATE_FILE_RELATIVE_PATH = join('.claude', 'mellos-mapping.json');
|
|
671
|
-
const LEGACY_PAGES_DIR_NAME = 'mellos-mapping.pages';
|
|
672
|
-
const LEGACY_CONFIG_FILE_NAME = 'mellos-mapping.config.json';
|
|
673
|
-
/**
|
|
674
|
-
* Move a legacy `.claude` store — map, pages, and mapping-policy config —
|
|
675
|
-
* into the tool-owned `.mellos` location. Never merges: a project whose new
|
|
676
|
-
* store already holds anything keeps it untouched, whatever the legacy
|
|
677
|
-
* directory still contains.
|
|
678
|
-
* @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
|
|
679
|
-
* the legacy store is looked up relative to `<root>`.
|
|
680
|
-
* @returns whether a legacy store was moved.
|
|
681
|
-
*/
|
|
682
|
-
export function migrateLegacyStore(defaultFile) {
|
|
683
|
-
const projectRoot = dirname(dirname(defaultFile));
|
|
684
|
-
const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
|
|
685
|
-
const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
|
|
686
|
-
const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
|
|
687
|
-
const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
|
|
688
|
-
const hasCurrent = existsSync(defaultFile)
|
|
689
|
-
|| existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
|
|
690
|
-
|| existsSync(configFilePath(defaultFile));
|
|
691
|
-
if (!hasLegacy || hasCurrent)
|
|
692
|
-
return false;
|
|
693
|
-
mkdirSync(dirname(defaultFile), { recursive: true });
|
|
694
|
-
if (existsSync(legacyDefault))
|
|
695
|
-
renameSync(legacyDefault, defaultFile);
|
|
696
|
-
if (existsSync(legacyPages))
|
|
697
|
-
renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
|
|
698
|
-
if (existsSync(legacyConfig))
|
|
699
|
-
renameSync(legacyConfig, configFilePath(defaultFile));
|
|
700
|
-
return true;
|
|
701
|
-
}
|
|
702
|
-
/** Load and validate the map file at `path`. */
|
|
703
|
-
export function loadMapFile(path) {
|
|
704
|
-
let text;
|
|
705
|
-
try {
|
|
706
|
-
text = readFileSync(path, 'utf8');
|
|
707
|
-
}
|
|
708
|
-
catch (e) {
|
|
709
|
-
const code = e.code;
|
|
710
|
-
if (code === 'ENOENT')
|
|
711
|
-
return err({ kind: 'not-found', path });
|
|
712
|
-
throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
|
|
713
|
-
}
|
|
714
|
-
let raw;
|
|
715
|
-
try {
|
|
716
|
-
raw = JSON.parse(stripBom(text));
|
|
717
|
-
}
|
|
718
|
-
catch (e) {
|
|
719
|
-
// Expected at this boundary: hand-edited files, or a reader racing a
|
|
720
|
-
// non-atomic writer from a foreign tool.
|
|
721
|
-
return err({ kind: 'malformed-json', path, detail: e.message });
|
|
722
|
-
}
|
|
723
|
-
return parseMap(raw, path);
|
|
724
|
-
}
|
|
725
|
-
/**
|
|
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.
|
|
731
|
-
*/
|
|
732
|
-
export function saveMapFile(path, map) {
|
|
733
|
-
return writeFileAtomic(path, serializeMap(map));
|
|
734
|
-
}
|
|
43
|
+
// Stable public facade. Internal modules depend on primitives, never on this barrel.
|
|
44
|
+
export { writeFileAtomic } from './atomic.js';
|
|
45
|
+
export { STORE_DIR_NAME, STATE_FILE_RELATIVE_PATH, PAGES_DIR_NAME, pageFilePath, pageIdOfFile, listPageFiles, deletePageFile } from './pages.js';
|
|
46
|
+
export { FOCUS_FILE_NAME, focusFilePath, takeFocusRequest, QUIT_FILE_NAME, quitFilePath, takeQuitRequest, sweepQuitRequest } from './channels.js';
|
|
47
|
+
export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STALE_MS, VIEWER_SWEEP_MS, viewersDirPath, viewerFilePath, publishViewer, retireViewer, readLiveViewers } from './viewers.js';
|
|
48
|
+
export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, effectiveMappingPolicy } from './policy.js';
|
|
49
|
+
export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
|
|
50
|
+
export { loadMapFile, saveMapFile } from './maps.js';
|