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.
Files changed (68) hide show
  1. package/README.md +125 -88
  2. package/README.zh-CN.md +122 -84
  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 +1198 -400
  7. package/dist/store-paths.mjs +40 -21
  8. package/dist/terminal-worker.mjs +3188 -0
  9. package/dist/watch.mjs +523 -280
  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/domain/types.js +10 -1
  23. package/lib/preview/index.d.ts +3 -0
  24. package/lib/preview/index.js +3 -0
  25. package/lib/preview/markdown.d.ts +8 -0
  26. package/lib/preview/markdown.js +54 -0
  27. package/lib/preview/presentation.d.ts +6 -0
  28. package/lib/preview/presentation.js +14 -0
  29. package/lib/preview/publisher.d.ts +23 -0
  30. package/lib/preview/publisher.js +143 -0
  31. package/lib/preview/svg.d.ts +3 -0
  32. package/lib/preview/svg.js +74 -0
  33. package/lib/preview/text.d.ts +4 -0
  34. package/lib/preview/text.js +13 -0
  35. package/lib/render/canvas.d.ts +1 -1
  36. package/lib/render/canvas.js +4 -2
  37. package/lib/render/draw.js +9 -6
  38. package/lib/render/render.d.ts +6 -0
  39. package/lib/render/render.js +50 -18
  40. package/lib/render/width.js +3 -1
  41. package/lib/store/atomic.d.ts +18 -0
  42. package/lib/store/atomic.js +86 -0
  43. package/lib/store/channels.d.ts +40 -0
  44. package/lib/store/channels.js +135 -0
  45. package/lib/store/format.js +3 -1
  46. package/lib/store/json-text.d.ts +9 -0
  47. package/lib/store/json-text.js +16 -0
  48. package/lib/store/maps.d.ts +12 -0
  49. package/lib/store/maps.js +42 -0
  50. package/lib/store/migration.d.ts +12 -0
  51. package/lib/store/migration.js +46 -0
  52. package/lib/store/pages.d.ts +46 -0
  53. package/lib/store/pages.js +89 -0
  54. package/lib/store/policy.d.ts +74 -0
  55. package/lib/store/policy.js +144 -0
  56. package/lib/store/store.d.ts +9 -256
  57. package/lib/store/store.js +10 -694
  58. package/lib/store/viewers.d.ts +81 -0
  59. package/lib/store/viewers.js +186 -0
  60. package/package.json +25 -6
  61. package/scripts/codex-cli.mjs +42 -0
  62. package/scripts/codex-register.mjs +27 -94
  63. package/scripts/mmap.mjs +26 -16
  64. package/scripts/open-pane.mjs +37 -18
  65. package/scripts/pane-core.mjs +57 -220
  66. package/scripts/terminal-session.mjs +137 -0
  67. package/scripts/tmux-session.mjs +90 -0
  68. package/scripts/watcher-command.mjs +16 -0
@@ -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), which is how a writer can tell whether anybody
7
- * is actually SEEING the map it is updating. The file FORMAT (version, page-id
6
+ * 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
- // atomic writes — the one primitive every save in this module is built on
49
- // ---------------------------------------------------------------------------
50
- /**
51
- * How many times a rename is attempted before the save is reported failed.
52
- * A reader's open handle blocks a rename on Windows for as long as it holds
53
- * the file; the watcher reads a page in well under a tick, so a handful of
54
- * attempts spans far more than any legitimate reader needs.
55
- */
56
- const RENAME_MAX_ATTEMPTS = 10;
57
- /** Backoff granularity: attempt N waits N * this, so ten attempts span ~450ms. */
58
- const RENAME_BACKOFF_STEP_MS = 10;
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';