mellos-mapping 0.20.3 → 0.23.0
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 +130 -69
- package/README.zh-CN.md +126 -65
- package/dist/hook-session-start.mjs +28 -20
- package/dist/mmap.mjs +303 -171
- package/dist/preview.mjs +1457 -0
- package/dist/server.mjs +1984 -741
- package/dist/store-paths.mjs +69 -22
- package/dist/terminal-worker.mjs +3331 -0
- package/dist/watch.mjs +957 -571
- 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 +5646 -0
- package/docs/codex.md +189 -0
- package/docs/map-api.md +148 -0
- package/lib/domain/context.d.ts +11 -0
- package/lib/domain/context.js +27 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +54 -0
- package/lib/domain/types.d.ts +3 -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 +21 -4
- 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/project.d.ts +2 -0
- package/lib/store/project.js +29 -0
- package/lib/store/store.d.ts +15 -257
- package/lib/store/store.js +16 -695
- package/lib/store/transaction.d.ts +12 -0
- package/lib/store/transaction.js +91 -0
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +27 -5
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +27 -17
- 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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MellosMap } from '../domain/types.js';
|
|
2
|
+
export declare class LedgerError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly details: Record<string, unknown>;
|
|
5
|
+
constructor(code: string, message: string, details?: Record<string, unknown>);
|
|
6
|
+
}
|
|
7
|
+
export declare const revisionOf: (map: MellosMap) => string;
|
|
8
|
+
export declare function assertRevision(actual: string, expected?: string): void;
|
|
9
|
+
/** A named page and the default page share the same project lock. */
|
|
10
|
+
export declare function storeDirectory(file: string): string;
|
|
11
|
+
/** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
|
|
12
|
+
export declare function withStoreLock<T>(file: string, action: () => T): T;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Cooperative, cross-process transactions for MCP and HTTP writers. */
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { serializeMap } from './format.js';
|
|
6
|
+
export class LedgerError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
details;
|
|
9
|
+
constructor(code, message, details = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const revisionOf = (map) => createHash('sha256').update(serializeMap(map)).digest('hex');
|
|
16
|
+
export function assertRevision(actual, expected) {
|
|
17
|
+
if (expected !== undefined && expected !== actual)
|
|
18
|
+
throw new LedgerError('CONFLICT', 'Map changed; read the current revision before retrying.', { expectedRevision: expected, actualRevision: actual });
|
|
19
|
+
}
|
|
20
|
+
/** A named page and the default page share the same project lock. */
|
|
21
|
+
export function storeDirectory(file) {
|
|
22
|
+
const dir = dirname(file);
|
|
23
|
+
return dir.endsWith('/pages') || dir.endsWith('\\pages') ? dirname(dir) : dir;
|
|
24
|
+
}
|
|
25
|
+
function deadOwner(lock) {
|
|
26
|
+
try {
|
|
27
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
28
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(owner.pid, 0);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return e.code === 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
|
|
43
|
+
export function withStoreLock(file, action) {
|
|
44
|
+
const dir = storeDirectory(file);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
const lock = join(dir, '.write-lock');
|
|
47
|
+
const owner = join(lock, 'owner.json');
|
|
48
|
+
const token = randomUUID();
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(lock);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== 'EEXIST')
|
|
54
|
+
throw error;
|
|
55
|
+
// Reapers serialize inside the old directory and recheck its owner after acquiring.
|
|
56
|
+
// Missing/invalid owners are deliberately not reclaimed on an age heuristic.
|
|
57
|
+
if (!deadOwner(lock))
|
|
58
|
+
throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(join(lock, '.reap'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
|
|
64
|
+
}
|
|
65
|
+
if (!deadOwner(lock)) {
|
|
66
|
+
rmSync(join(lock, '.reap'), { recursive: true, force: true });
|
|
67
|
+
throw new LedgerError('BUSY', 'Writer ownership changed.');
|
|
68
|
+
}
|
|
69
|
+
rmSync(lock, { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(lock);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new LedgerError('BUSY', 'Another writer acquired the recovered lock.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let initialized = false;
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(owner, JSON.stringify({ pid: process.pid, token }), { flag: 'wx' });
|
|
80
|
+
initialized = true;
|
|
81
|
+
return action();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
// Only remove the directory still owned by this invocation.
|
|
85
|
+
try {
|
|
86
|
+
if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
|
|
87
|
+
rmSync(lock, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Result } from '../domain/types.js';
|
|
2
|
+
import { type StoreError } from './format.js';
|
|
3
|
+
import { type PageId } from './format.js';
|
|
4
|
+
/** Directory (next to the default file) holding one report per live pane. */
|
|
5
|
+
export declare const VIEWERS_DIR_NAME = "viewers";
|
|
6
|
+
/** On-disk report format version. Bump only with a documented migration. */
|
|
7
|
+
export declare const VIEWER_FILE_VERSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* How often a pane refreshes its report — the pane's timer, and the unit the
|
|
10
|
+
* two thresholds below are counted in.
|
|
11
|
+
*/
|
|
12
|
+
export declare const VIEWER_HEARTBEAT_MS = 1000;
|
|
13
|
+
/**
|
|
14
|
+
* A report older than this is not a pane, it is a pane's remains. Five missed
|
|
15
|
+
* heartbeats: long enough to survive a garbage collection, a slow repaint or
|
|
16
|
+
* a busy disk, short enough that a closed pane is known closed before anyone
|
|
17
|
+
* asks twice.
|
|
18
|
+
*/
|
|
19
|
+
export declare const VIEWER_STALE_MS = 5000;
|
|
20
|
+
/**
|
|
21
|
+
* A report this old is deleted on sight by whoever reads it. A pane that was
|
|
22
|
+
* killed leaves its file behind forever, and a file that outlives every pane
|
|
23
|
+
* would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
|
|
24
|
+
* slack: a machine that just woke from sleep has stale reports whose panes
|
|
25
|
+
* are alive and about to beat again, and there is no reason to make them pay
|
|
26
|
+
* for the sleep with a deleted file.
|
|
27
|
+
*/
|
|
28
|
+
export declare const VIEWER_SWEEP_MS = 60000;
|
|
29
|
+
/** Where a store's viewer reports live, given the default page's file path. */
|
|
30
|
+
export declare function viewersDirPath(defaultFile: string): string;
|
|
31
|
+
/** Where ONE pane's report lives. The pid in the name is the pane's identity. */
|
|
32
|
+
export declare function viewerFilePath(defaultFile: string, pid: number): string;
|
|
33
|
+
/** What a pane says about itself while it is up. */
|
|
34
|
+
export interface ViewerReport {
|
|
35
|
+
/** The page on screen right now; undefined = the default page. */
|
|
36
|
+
readonly page: PageId | undefined;
|
|
37
|
+
/** Auto-follow: whether the pane will switch to whatever page is written next. */
|
|
38
|
+
readonly follow: boolean;
|
|
39
|
+
/** Launching console identity, or "window" for an explicitly separate pane. */
|
|
40
|
+
readonly owner?: string;
|
|
41
|
+
}
|
|
42
|
+
/** A report fresh enough to be a pane, with whose it is and how old. */
|
|
43
|
+
export interface LiveViewer extends ViewerReport {
|
|
44
|
+
readonly pid: number;
|
|
45
|
+
/** Age of the report in ms — how long ago that pane last said anything. */
|
|
46
|
+
readonly ageMs: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Publish this pane's report, atomically (P2) — a reader polling the file
|
|
50
|
+
* sees the previous whole report or the new one, never half of one.
|
|
51
|
+
*
|
|
52
|
+
* Preconditions: none; the viewers directory is created if missing.
|
|
53
|
+
* Postcondition on ok: the pane's file holds `report` and its mtime is now,
|
|
54
|
+
* which is what says the pane is alive. On error nothing about the pane is
|
|
55
|
+
* claimed, and the next heartbeat is the whole recovery — so a caller reports
|
|
56
|
+
* a failed beat at most once and keeps beating.
|
|
57
|
+
*/
|
|
58
|
+
export declare function publishViewer(defaultFile: string, pid: number, report: ViewerReport): Result<void, StoreError>;
|
|
59
|
+
/**
|
|
60
|
+
* Take this pane's report back — the pane is going away and says so, rather
|
|
61
|
+
* than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
|
|
62
|
+
*
|
|
63
|
+
* Best-effort by contract: an exit path is the worst place to raise, and a
|
|
64
|
+
* report nobody could delete goes stale on its own within seconds.
|
|
65
|
+
*/
|
|
66
|
+
export declare function retireViewer(defaultFile: string, pid: number): void;
|
|
67
|
+
/**
|
|
68
|
+
* Every pane currently showing this store, youngest report first.
|
|
69
|
+
*
|
|
70
|
+
* @param nowMs - the caller's clock (Date.now()), passed in so the ageing
|
|
71
|
+
* rules can be tested without waiting for real seconds to pass.
|
|
72
|
+
* @returns the live reports; an EMPTY array means nobody is seeing this map.
|
|
73
|
+
*
|
|
74
|
+
* Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
|
|
75
|
+
* pane that would have refreshed it is provably gone and no other code runs
|
|
76
|
+
* often enough to notice. A report between stale and sweep is ignored but
|
|
77
|
+
* kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
|
|
78
|
+
* report is: a viewer started by hand counts exactly like one the launcher
|
|
79
|
+
* opened, because the user can see both.
|
|
80
|
+
*/
|
|
81
|
+
export declare function readLiveViewers(defaultFile: string, nowMs: number): readonly LiveViewer[];
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { makePageId } from './format.js';
|
|
4
|
+
import { writeAtomic } from './atomic.js';
|
|
5
|
+
import { isRecord, stripBom } from './json-text.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// viewers — "somebody is looking at this store" reports, from the panes
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// The first channel that runs the OTHER way. focus and quit are messages TO a
|
|
11
|
+
// pane; this is a pane answering the question every other surface had to
|
|
12
|
+
// guess at, and mostly guessed wrong: is anyone actually SEEING this map?
|
|
13
|
+
//
|
|
14
|
+
// The guess was the bug. An assistant would declare a design, light nodes up
|
|
15
|
+
// as the work went, and report all of it into a store nobody had open —
|
|
16
|
+
// because opening the pane took a human typing `mmap`, and nothing in the
|
|
17
|
+
// system could tell that it had not happened. A map nobody can see is not a
|
|
18
|
+
// map; it is a file. So a pane now says it is there, and the launcher, the
|
|
19
|
+
// toggle and the MCP server read the same answer instead of inventing three.
|
|
20
|
+
//
|
|
21
|
+
// One file per pane, named by its pid: two panes on one store (a split and a
|
|
22
|
+
// dedicated window, `--force`) never clobber each other's report, and the
|
|
23
|
+
// name IS the identity, so nothing inside the payload repeats it.
|
|
24
|
+
//
|
|
25
|
+
// FRESHNESS IS THE FILE'S MTIME, and nothing else. A pane rewrites its report
|
|
26
|
+
// every VIEWER_HEARTBEAT_MS, so a report older than VIEWER_STALE_MS belongs
|
|
27
|
+
// to a pane that is gone — killed, closed with its window, or wedged. A
|
|
28
|
+
// timestamp INSIDE the payload would be a second truth about the same fact,
|
|
29
|
+
// free to disagree with the first; a pid checked for liveness would be worse
|
|
30
|
+
// still, because Windows recycles pids and a recycled one would report a
|
|
31
|
+
// phantom pane — exactly the lie this channel exists to end.
|
|
32
|
+
/** Directory (next to the default file) holding one report per live pane. */
|
|
33
|
+
export const VIEWERS_DIR_NAME = 'viewers';
|
|
34
|
+
/** On-disk report format version. Bump only with a documented migration. */
|
|
35
|
+
export const VIEWER_FILE_VERSION = 1;
|
|
36
|
+
/**
|
|
37
|
+
* How often a pane refreshes its report — the pane's timer, and the unit the
|
|
38
|
+
* two thresholds below are counted in.
|
|
39
|
+
*/
|
|
40
|
+
export const VIEWER_HEARTBEAT_MS = 1_000;
|
|
41
|
+
/**
|
|
42
|
+
* A report older than this is not a pane, it is a pane's remains. Five missed
|
|
43
|
+
* heartbeats: long enough to survive a garbage collection, a slow repaint or
|
|
44
|
+
* a busy disk, short enough that a closed pane is known closed before anyone
|
|
45
|
+
* asks twice.
|
|
46
|
+
*/
|
|
47
|
+
export const VIEWER_STALE_MS = 5_000;
|
|
48
|
+
/**
|
|
49
|
+
* A report this old is deleted on sight by whoever reads it. A pane that was
|
|
50
|
+
* killed leaves its file behind forever, and a file that outlives every pane
|
|
51
|
+
* would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
|
|
52
|
+
* slack: a machine that just woke from sleep has stale reports whose panes
|
|
53
|
+
* are alive and about to beat again, and there is no reason to make them pay
|
|
54
|
+
* for the sleep with a deleted file.
|
|
55
|
+
*/
|
|
56
|
+
export const VIEWER_SWEEP_MS = 60_000;
|
|
57
|
+
/** Where a store's viewer reports live, given the default page's file path. */
|
|
58
|
+
export function viewersDirPath(defaultFile) {
|
|
59
|
+
return join(dirname(defaultFile), VIEWERS_DIR_NAME);
|
|
60
|
+
}
|
|
61
|
+
/** Where ONE pane's report lives. The pid in the name is the pane's identity. */
|
|
62
|
+
export function viewerFilePath(defaultFile, pid) {
|
|
63
|
+
return join(viewersDirPath(defaultFile), `${pid}.json`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The pid a viewer file name denotes, or undefined when the name is not one
|
|
67
|
+
* of ours. Strict on purpose: the directory also holds the `*.tmp` files of
|
|
68
|
+
* saves in flight (writeFileAtomic), and a temp read as a report would be a
|
|
69
|
+
* pane that never existed.
|
|
70
|
+
*/
|
|
71
|
+
function viewerPidOf(fileName) {
|
|
72
|
+
const m = /^(\d+)\.json$/.exec(fileName);
|
|
73
|
+
return m === null ? undefined : Number(m[1]);
|
|
74
|
+
}
|
|
75
|
+
/** Read one report's payload; undefined for anything that is not one. */
|
|
76
|
+
function parseViewerReport(raw) {
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(stripBom(raw));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined; // a torn read from a foreign writer, or junk
|
|
83
|
+
}
|
|
84
|
+
if (!isRecord(parsed))
|
|
85
|
+
return undefined;
|
|
86
|
+
if (parsed['version'] !== VIEWER_FILE_VERSION)
|
|
87
|
+
return undefined;
|
|
88
|
+
const follow = parsed['follow'];
|
|
89
|
+
if (typeof follow !== 'boolean')
|
|
90
|
+
return undefined;
|
|
91
|
+
const owner = parsed['owner'];
|
|
92
|
+
if (owner !== undefined && (typeof owner !== 'string' || !makePageId(owner).ok))
|
|
93
|
+
return undefined;
|
|
94
|
+
const binding = owner === undefined ? {} : { owner: owner };
|
|
95
|
+
const page = parsed['page'];
|
|
96
|
+
if (page === null || page === undefined)
|
|
97
|
+
return { page: undefined, follow, ...binding };
|
|
98
|
+
if (typeof page !== 'string')
|
|
99
|
+
return undefined;
|
|
100
|
+
const id = makePageId(page);
|
|
101
|
+
return id.ok ? { page: id.value, follow, ...binding } : undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Publish this pane's report, atomically (P2) — a reader polling the file
|
|
105
|
+
* sees the previous whole report or the new one, never half of one.
|
|
106
|
+
*
|
|
107
|
+
* Preconditions: none; the viewers directory is created if missing.
|
|
108
|
+
* Postcondition on ok: the pane's file holds `report` and its mtime is now,
|
|
109
|
+
* which is what says the pane is alive. On error nothing about the pane is
|
|
110
|
+
* claimed, and the next heartbeat is the whole recovery — so a caller reports
|
|
111
|
+
* a failed beat at most once and keeps beating.
|
|
112
|
+
*/
|
|
113
|
+
export function publishViewer(defaultFile, pid, report) {
|
|
114
|
+
const body = { version: VIEWER_FILE_VERSION, page: report.page ?? null, follow: report.follow, owner: report.owner };
|
|
115
|
+
// This disposable report runs on the input thread every second. A locked
|
|
116
|
+
// target keeps its previous complete report; the next heartbeat retries.
|
|
117
|
+
return writeAtomic(viewerFilePath(defaultFile, pid), `${JSON.stringify(body, null, 2)}\n`, 1);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Take this pane's report back — the pane is going away and says so, rather
|
|
121
|
+
* than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
|
|
122
|
+
*
|
|
123
|
+
* Best-effort by contract: an exit path is the worst place to raise, and a
|
|
124
|
+
* report nobody could delete goes stale on its own within seconds.
|
|
125
|
+
*/
|
|
126
|
+
export function retireViewer(defaultFile, pid) {
|
|
127
|
+
try {
|
|
128
|
+
rmSync(viewerFilePath(defaultFile, pid), { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// The file outlives us by VIEWER_STALE_MS. That is the whole cost.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Every pane currently showing this store, youngest report first.
|
|
136
|
+
*
|
|
137
|
+
* @param nowMs - the caller's clock (Date.now()), passed in so the ageing
|
|
138
|
+
* rules can be tested without waiting for real seconds to pass.
|
|
139
|
+
* @returns the live reports; an EMPTY array means nobody is seeing this map.
|
|
140
|
+
*
|
|
141
|
+
* Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
|
|
142
|
+
* pane that would have refreshed it is provably gone and no other code runs
|
|
143
|
+
* often enough to notice. A report between stale and sweep is ignored but
|
|
144
|
+
* kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
|
|
145
|
+
* report is: a viewer started by hand counts exactly like one the launcher
|
|
146
|
+
* opened, because the user can see both.
|
|
147
|
+
*/
|
|
148
|
+
export function readLiveViewers(defaultFile, nowMs) {
|
|
149
|
+
const dir = viewersDirPath(defaultFile);
|
|
150
|
+
let names;
|
|
151
|
+
try {
|
|
152
|
+
names = readdirSync(dir);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return []; // no directory — no pane has ever run here, the common case
|
|
156
|
+
}
|
|
157
|
+
const live = [];
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const pid = viewerPidOf(name);
|
|
160
|
+
if (pid === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
const path = join(dir, name);
|
|
163
|
+
let raw;
|
|
164
|
+
let ageMs;
|
|
165
|
+
try {
|
|
166
|
+
// stat BEFORE the read: a report that goes stale between the two calls
|
|
167
|
+
// is merely counted a millisecond young, while the reverse order would
|
|
168
|
+
// age every report by however long its read took.
|
|
169
|
+
ageMs = Math.max(0, nowMs - statSync(path).mtimeMs);
|
|
170
|
+
if (ageMs > VIEWER_SWEEP_MS) {
|
|
171
|
+
rmSync(path, { force: true });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ageMs > VIEWER_STALE_MS)
|
|
175
|
+
continue;
|
|
176
|
+
raw = readFileSync(path, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
continue; // vanished or unreadable under us: not a pane we can speak for
|
|
180
|
+
}
|
|
181
|
+
const report = parseViewerReport(raw);
|
|
182
|
+
if (report !== undefined)
|
|
183
|
+
live.push({ ...report, pid, ageMs });
|
|
184
|
+
}
|
|
185
|
+
return live.sort((a, b) => a.ageMs - b.ageMs || a.pid - b.pid);
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mellos-mapping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"mcpName": "io.github.GuangminJu/mellos-mapping",
|
|
5
5
|
"description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"bin": {
|
|
27
27
|
"mellos-mapping": "dist/server.mjs",
|
|
28
28
|
"mellos-mapping-watch": "dist/watch.mjs",
|
|
29
|
+
"mellos-mapping-preview": "dist/preview.mjs",
|
|
30
|
+
"mellos-mapping-web": "dist/web.mjs",
|
|
29
31
|
"mmap": "dist/mmap.mjs"
|
|
30
32
|
},
|
|
31
33
|
"exports": {
|
|
@@ -54,6 +56,10 @@
|
|
|
54
56
|
"default": "./lib/render/render.js"
|
|
55
57
|
},
|
|
56
58
|
"./server": "./dist/server.mjs",
|
|
59
|
+
"./preview": {
|
|
60
|
+
"types": "./lib/preview/index.d.ts",
|
|
61
|
+
"default": "./lib/preview/index.js"
|
|
62
|
+
},
|
|
57
63
|
"./package.json": "./package.json"
|
|
58
64
|
},
|
|
59
65
|
"publishConfig": {
|
|
@@ -63,29 +69,45 @@
|
|
|
63
69
|
"dist",
|
|
64
70
|
"lib",
|
|
65
71
|
"README.zh-CN.md",
|
|
72
|
+
"docs/codex.md",
|
|
73
|
+
"docs/map-api.md",
|
|
66
74
|
"scripts/codex-register.mjs",
|
|
75
|
+
"scripts/codex-cli.mjs",
|
|
67
76
|
"scripts/install-mmap-command.mjs",
|
|
68
77
|
"scripts/mmap.mjs",
|
|
69
78
|
"scripts/open-pane.mjs",
|
|
70
|
-
"scripts/pane-core.mjs"
|
|
79
|
+
"scripts/pane-core.mjs",
|
|
80
|
+
"scripts/terminal-session.mjs",
|
|
81
|
+
"scripts/tmux-session.mjs",
|
|
82
|
+
"scripts/watcher-command.mjs"
|
|
71
83
|
],
|
|
72
84
|
"engines": {
|
|
73
85
|
"node": ">=18"
|
|
74
86
|
},
|
|
75
87
|
"scripts": {
|
|
76
88
|
"test": "vitest run",
|
|
77
|
-
"typecheck": "tsc --noEmit",
|
|
89
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
78
90
|
"build": "node build.mjs && tsc -p tsconfig.lib.json",
|
|
91
|
+
"package:codex": "npm run build && node scripts/package-codex.mjs",
|
|
92
|
+
"package:release": "node scripts/package-release.mjs",
|
|
93
|
+
"check:release": "node scripts/check-release.mjs",
|
|
79
94
|
"check:package": "node scripts/check-package-surface.mjs",
|
|
95
|
+
"check:codex": "node scripts/check-codex-package.mjs",
|
|
96
|
+
"check:reuse": "node scripts/check-reuse.mjs",
|
|
97
|
+
"benchmark:render": "node scripts/benchmark-render.mjs",
|
|
80
98
|
"prepack": "npm run build",
|
|
81
|
-
"verify": "npm run typecheck && npm run test && npm run build && npm run check:package"
|
|
99
|
+
"verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
|
|
82
100
|
},
|
|
83
101
|
"devDependencies": {
|
|
84
102
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
85
103
|
"@types/node": "^24.0.0",
|
|
104
|
+
"@types/ws": "8.18.1",
|
|
105
|
+
"@xterm/addon-fit": "0.11.0",
|
|
106
|
+
"@xterm/xterm": "6.0.0",
|
|
86
107
|
"esbuild": "^0.25.0",
|
|
87
108
|
"typescript": "^5.8.0",
|
|
88
|
-
"vitest": "^
|
|
109
|
+
"vitest": "^4.1.11",
|
|
110
|
+
"ws": "8.21.3",
|
|
89
111
|
"zod": "^3.24.0"
|
|
90
112
|
}
|
|
91
113
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Codex process adapter. Arguments never pass through a command shell. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export function isFile(path) {
|
|
8
|
+
try { return statSync(path).isFile(); } catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve native Codex or the official npm shim's JS entry on Windows. */
|
|
12
|
+
export function resolveCodexInvocation({
|
|
13
|
+
platform = process.platform, env = process.env, nodePath = process.execPath, fileExists = isFile,
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (platform !== 'win32') return { command: 'codex', args: [] };
|
|
16
|
+
const path = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] ?? '';
|
|
17
|
+
for (const raw of path.split(';')) {
|
|
18
|
+
const dir = raw.replace(/^"|"$/g, '');
|
|
19
|
+
if (!dir) continue;
|
|
20
|
+
const executable = join(dir, 'codex.exe');
|
|
21
|
+
if (fileExists(executable)) return { command: executable, args: [] };
|
|
22
|
+
const entry = join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
|
|
23
|
+
if (fileExists(entry) && (fileExists(join(dir, 'codex.cmd')) || fileExists(join(dir, 'codex.ps1')))) {
|
|
24
|
+
return { command: nodePath, args: [entry] };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error('codex CLI not found on PATH — install Codex first (native executable or official npm package).');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runCodex(args) {
|
|
31
|
+
const cli = resolveCodexInvocation();
|
|
32
|
+
// Keep diagnostics as bytes; JSON callers decode their own structured output.
|
|
33
|
+
return spawnSync(cli.command, [...cli.args, ...args], {
|
|
34
|
+
stdio: 'pipe', windowsHide: true, timeout: 30_000,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function launchedAsEntry(moduleUrl) {
|
|
39
|
+
try {
|
|
40
|
+
return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(moduleUrl));
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
@@ -1,117 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
2
3
|
/**
|
|
3
|
-
* Register the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Why this script exists instead of a plugin-shipped .mcp.json: Codex spawns
|
|
7
|
-
* plugin-bundled MCP servers inside the plugin cache and gives them no way to
|
|
8
|
-
* learn the user's workspace (no ${PLUGIN_ROOT}-style expansion in args, cwd
|
|
9
|
-
* locked to the plugin root when set — verified empirically against
|
|
10
|
-
* codex-cli 0.147.0). The state file would land in the cache instead of the
|
|
11
|
-
* project. A user-level `codex mcp add` entry inherits the session's working
|
|
12
|
-
* directory, which is exactly the contract dist/server.mjs already expects.
|
|
13
|
-
* The registered path is absolute and version-specific, so re-run after
|
|
14
|
-
* every plugin update.
|
|
15
|
-
*
|
|
16
|
-
* Two Windows facts shape the process handling below:
|
|
17
|
-
* - A missing `codex` is NOT a spawn error there. The call goes through
|
|
18
|
-
* cmd.exe (only a shell resolves both codex.exe and an npm .cmd shim), so
|
|
19
|
-
* "not recognized as an internal or external command" arrives as an
|
|
20
|
-
* ordinary non-zero exit, and a check for spawnSync's `error` field can
|
|
21
|
-
* never fire. PATH is probed up front instead.
|
|
22
|
-
* - The child writes its diagnostics in the console's OEM code page.
|
|
23
|
-
* Decoding those bytes as UTF-8 turns every non-English message into
|
|
24
|
-
* mojibake, so they are passed through as bytes and the terminal decodes
|
|
25
|
-
* them as it decodes everything else it prints.
|
|
26
|
-
*
|
|
27
|
-
* Pure helpers are exported for the spec; registration runs only as an entry
|
|
28
|
-
* point, so importing this file is inert.
|
|
4
|
+
* Register the shared runtime at user scope so it inherits each session's
|
|
5
|
+
* working directory. The skill bundle and the runtime are separate host
|
|
6
|
+
* adapters over the same core; no workspace path is baked into an install.
|
|
29
7
|
*/
|
|
30
|
-
import { spawnSync } from 'node:child_process';
|
|
31
|
-
import { realpathSync } from 'node:fs';
|
|
32
8
|
import { dirname, join } from 'node:path';
|
|
33
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { isFile, launchedAsEntry, runCodex } from './codex-cli.mjs';
|
|
34
11
|
|
|
35
|
-
/** cmd.exe's exit code for a command it cannot resolve on PATH. */
|
|
36
12
|
export const COMMAND_NOT_FOUND_EXIT_CODE = 9009;
|
|
37
13
|
|
|
38
|
-
/**
|
|
39
|
-
export function quoteForShell(arg) {
|
|
40
|
-
return /[\s"]/.test(arg) ? `"${arg}"` : arg;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* What went wrong, in the user's terms.
|
|
45
|
-
* @param status - the child's exit code (null = it never ran).
|
|
46
|
-
* @returns the line to print; a missing CLI is named as such on every
|
|
47
|
-
* platform, because that is the failure a first-time reader actually hits.
|
|
48
|
-
*/
|
|
14
|
+
/** @param {number | null} status */
|
|
49
15
|
export function describeFailure(status) {
|
|
50
16
|
return status === COMMAND_NOT_FOUND_EXIT_CODE || status === null
|
|
51
17
|
? 'codex CLI not found on PATH — install Codex first, then re-run this script.'
|
|
52
18
|
: `codex mcp add failed (exit ${status}).`;
|
|
53
19
|
}
|
|
54
20
|
|
|
55
|
-
/** Whether `codex` resolves on PATH. */
|
|
56
|
-
function codexOnPath() {
|
|
57
|
-
const probe =
|
|
58
|
-
process.platform === 'win32'
|
|
59
|
-
? spawnSync('where', ['codex'], { stdio: 'ignore', windowsHide: true })
|
|
60
|
-
: spawnSync('command', ['-v', 'codex'], { shell: true, stdio: 'ignore' });
|
|
61
|
-
return probe.error === undefined && probe.status === 0;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
21
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
22
|
+
* Validate before changing configuration. `mcp add` replaces its named entry
|
|
23
|
+
* in one operation; removing first creates an unnecessary failure window.
|
|
24
|
+
* Dependencies are supplied here so tests cannot change the real user config.
|
|
25
|
+
* @param {string} pluginRoot
|
|
26
|
+
* @param {{run?: import('./install-types.js').HostRunner, nodePath?: string, fileExists?: (path: string) => boolean}} [options]
|
|
67
27
|
*/
|
|
68
|
-
function
|
|
69
|
-
if (process.platform !== 'win32') {
|
|
70
|
-
return spawnSync('codex', args, { stdio: 'pipe' });
|
|
71
|
-
}
|
|
72
|
-
// Windows: codex may be an .exe or an npm .cmd shim; only a shell resolves
|
|
73
|
-
// both. The shell needs explicit quoting for paths with spaces.
|
|
74
|
-
const line = ['codex', ...args].map(quoteForShell).join(' ');
|
|
75
|
-
return spawnSync(line, { shell: true, stdio: 'pipe', windowsHide: true });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function main() {
|
|
79
|
-
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
28
|
+
export function registerServer(pluginRoot, { run = runCodex, nodePath = process.execPath, fileExists = isFile } = {}) {
|
|
80
29
|
const serverPath = join(pluginRoot, 'dist', 'server.mjs');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Idempotent: drop any previous (possibly stale, version-specific) entry.
|
|
88
|
-
codex(['mcp', 'remove', 'mellos-mapping']);
|
|
89
|
-
|
|
90
|
-
const added = codex(['mcp', 'add', 'mellos-mapping', '--', 'node', serverPath]);
|
|
91
|
-
if (added.status !== 0) {
|
|
92
|
-
if (added.stderr) process.stderr.write(added.stderr);
|
|
93
|
-
console.error(describeFailure(added.status));
|
|
94
|
-
process.exit(1);
|
|
30
|
+
if (!fileExists(serverPath)) throw new Error(`Bundled MCP server is missing: ${serverPath}. Build or reinstall the plugin first.`);
|
|
31
|
+
const result = run(['mcp', 'add', 'mellos-mapping', '--', nodePath, serverPath]);
|
|
32
|
+
if (result.error) throw new Error(`Codex registration could not run: ${result.error.message}`);
|
|
33
|
+
if (result.status !== 0) {
|
|
34
|
+
const detail = result.stderr?.toString().trim();
|
|
35
|
+
throw new Error(`${describeFailure(result.status)}${detail ? `\n${detail}` : ''}`);
|
|
95
36
|
}
|
|
96
|
-
|
|
97
|
-
console.log(`mellos-mapping MCP registered with Codex: node ${serverPath}`);
|
|
98
|
-
console.log('State files resolve to each session’s working directory (.mellos/map.json).');
|
|
37
|
+
return { serverPath, nodePath };
|
|
99
38
|
}
|
|
100
39
|
|
|
101
|
-
|
|
102
|
-
* Run only as an entry point, so the spec can import the helpers above
|
|
103
|
-
* without registering anything. Each script in this directory carries its own
|
|
104
|
-
* copy: package.json ships them file-by-file, so a shared scripts/ module
|
|
105
|
-
* would simply be missing from an npm install.
|
|
106
|
-
*/
|
|
107
|
-
function launchedAsEntry() {
|
|
108
|
-
const entry = process.argv[1];
|
|
109
|
-
if (entry === undefined) return false;
|
|
40
|
+
if (launchedAsEntry(import.meta.url)) {
|
|
110
41
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
42
|
+
const installed = registerServer(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
43
|
+
console.log(`mellos-mapping MCP registered with Codex: ${installed.nodePath} ${installed.serverPath}`);
|
|
44
|
+
console.log('State files resolve to each session’s working directory (.mellos/map.json).');
|
|
45
|
+
console.log('Start a new Codex conversation to load the skills and eight mmap tools.');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
48
|
+
process.exitCode = 1;
|
|
114
49
|
}
|
|
115
50
|
}
|
|
116
|
-
|
|
117
|
-
if (launchedAsEntry()) main();
|