mellos-mapping 0.20.0 → 0.20.2

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 (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -9,6 +9,11 @@
9
9
  * operations, so a hand-edited or corrupted file can never smuggle an
10
10
  * invariant violation into the process (validate at the boundary,
11
11
  * trust internal code afterwards).
12
+ * The shape is read STRICTLY: a wrong type is refused, never coerced
13
+ * and never quietly dropped. The reason is that the store round-trips —
14
+ * a lenient read of `{"nodes": {...}}` as "no nodes" would be written
15
+ * back by the next save, so leniency here does not tolerate a damaged
16
+ * file, it destroys one.
12
17
  * F1. serializeMap is the inverse of parseMap for valid maps.
13
18
  *
14
19
  * No node:* imports — this module must load in a browser as-is. Filesystem
@@ -16,7 +21,7 @@
16
21
  * ./store.ts, the Node-side half.
17
22
  */
18
23
  import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
19
- import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeSubmapRef, ok, } from '../domain/types.js';
24
+ import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
20
25
  /** On-disk format version. Bump only with a documented migration. */
21
26
  export const STATE_FILE_VERSION = 1;
22
27
  export function makePageId(raw) {
@@ -32,16 +37,57 @@ export function describeStoreError(e) {
32
37
  return `map file ${e.path} has an unexpected shape: ${e.detail}`;
33
38
  case 'invariant-violation':
34
39
  return `map file ${e.path} violates a structural invariant: ${describeMapError(e.violation)}`;
40
+ case 'save-failed':
41
+ return `could not write ${e.path}: ${e.detail}`;
42
+ case 'delete-failed':
43
+ return `could not delete ${e.path}: ${e.detail}`;
35
44
  }
36
45
  }
37
46
  function isRecord(v) {
38
47
  return typeof v === 'object' && v !== null && !Array.isArray(v);
39
48
  }
40
- function asArray(v) {
41
- return Array.isArray(v) ? v : [];
49
+ /** How a refused value is named in a bad-shape detail. */
50
+ function describeValue(v) {
51
+ if (v === undefined)
52
+ return 'missing';
53
+ if (v === null)
54
+ return 'null';
55
+ if (Array.isArray(v))
56
+ return 'an array';
57
+ return `a ${typeof v}`;
42
58
  }
43
- function optionalString(v) {
44
- return typeof v === 'string' ? v : undefined;
59
+ function badShape(path, where, expected, got) {
60
+ return err({ kind: 'bad-shape', path, detail: `${where} is ${describeValue(got)}, expected ${expected}` });
61
+ }
62
+ /**
63
+ * A list field of the file.
64
+ * @param presence - 'optional' lets the KEY be absent (an unused feature
65
+ * writes no key at all); a present non-array is refused either way.
66
+ *
67
+ * Coercing a non-array to an empty list is the one shape mistake this
68
+ * boundary must never make: `{"nodes": {...}}` would parse as a map with no
69
+ * nodes, and the next save would write that empty interpretation over the
70
+ * file — the boundary erasing the data it exists to protect.
71
+ */
72
+ function arrayField(raw, key, path, presence) {
73
+ const v = raw[key];
74
+ if (Array.isArray(v))
75
+ return ok(v);
76
+ if (v === undefined && presence === 'optional')
77
+ return ok([]);
78
+ return badShape(path, `"${key}"`, 'an array', v);
79
+ }
80
+ /** A field that must be a string. Numbers and booleans are refused, never coerced. */
81
+ function requiredString(rec, key, where, path) {
82
+ const v = rec[key];
83
+ return typeof v === 'string' ? ok(v) : badShape(path, `${where}.${key}`, 'a string', v);
84
+ }
85
+ /** A field that may be absent, but must be a string when present — never dropped for being the wrong type. */
86
+ function optionalString(rec, key, where, path) {
87
+ const v = rec[key];
88
+ if (v === undefined)
89
+ return ok(undefined);
90
+ return typeof v === 'string' ? ok(v) : badShape(path, `${where}.${key}`, 'a string', v);
45
91
  }
46
92
  /**
47
93
  * Rebuild a MellosMap from untrusted raw data by replaying it through the
@@ -55,118 +101,179 @@ export function parseMap(raw, path) {
55
101
  if (raw['version'] !== STATE_FILE_VERSION) {
56
102
  return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
57
103
  }
104
+ const layers = arrayField(raw, 'layers', path, 'required');
105
+ if (!layers.ok)
106
+ return layers;
107
+ const nodes = arrayField(raw, 'nodes', path, 'required');
108
+ if (!nodes.ok)
109
+ return nodes;
110
+ const edges = arrayField(raw, 'edges', path, 'required');
111
+ if (!edges.ok)
112
+ return edges;
113
+ const lanes = arrayField(raw, 'lanes', path, 'optional');
114
+ if (!lanes.ok)
115
+ return lanes;
116
+ const groups = arrayField(raw, 'groups', path, 'optional');
117
+ if (!groups.ok)
118
+ return groups;
58
119
  let map = EMPTY_MAP;
59
- const title = optionalString(raw['title']);
60
- if (title !== undefined)
61
- map = setTitle(map, title);
62
- const rawKind = optionalString(raw['kind']);
63
- if (rawKind !== undefined) {
64
- const kind = makeMapKind(rawKind);
120
+ const title = optionalString(raw, 'title', 'map', path);
121
+ if (!title.ok)
122
+ return title;
123
+ if (title.value !== undefined)
124
+ map = setTitle(map, title.value);
125
+ const rawKind = optionalString(raw, 'kind', 'map', path);
126
+ if (!rawKind.ok)
127
+ return rawKind;
128
+ if (rawKind.value !== undefined) {
129
+ const kind = makeMapKind(rawKind.value);
65
130
  if (!kind.ok)
66
131
  return err({ kind: 'invariant-violation', path, violation: kind.error });
67
132
  map = setKind(map, kind.value);
68
133
  }
69
- for (const [i, rawLayer] of asArray(raw['layers']).entries()) {
134
+ for (const [i, rawLayer] of layers.value.entries()) {
135
+ const where = `layers[${i}]`;
70
136
  if (!isRecord(rawLayer))
71
- return err({ kind: 'bad-shape', path, detail: `layers[${i}] is not an object` });
72
- const id = makeLayerId(String(rawLayer['id'] ?? ''));
137
+ return badShape(path, where, 'an object', rawLayer);
138
+ const rawId = requiredString(rawLayer, 'id', where, path);
139
+ if (!rawId.ok)
140
+ return rawId;
141
+ const id = makeLayerId(rawId.value);
73
142
  if (!id.ok)
74
143
  return err({ kind: 'invariant-violation', path, violation: id.error });
75
- const name = optionalString(rawLayer['name']);
76
- const rank = rawLayer['rank'];
77
- if (name === undefined || typeof rank !== 'number' || !Number.isInteger(rank)) {
78
- return err({ kind: 'bad-shape', path, detail: `layers[${i}] needs a string name and an integer rank` });
79
- }
80
- const next = declareLayer(map, { id: id.value, name, rank });
144
+ const name = requiredString(rawLayer, 'name', where, path);
145
+ if (!name.ok)
146
+ return name;
147
+ const rawRank = rawLayer['rank'];
148
+ if (typeof rawRank !== 'number')
149
+ return badShape(path, `${where}.rank`, 'a number', rawRank);
150
+ // The RANGE and integrality of a rank are the domain's rule (makeRank),
151
+ // never restated here: a file the format accepted but the domain refuses
152
+ // is exactly the drift this boundary exists to prevent.
153
+ const rank = makeRank(rawRank);
154
+ if (!rank.ok)
155
+ return err({ kind: 'invariant-violation', path, violation: rank.error });
156
+ const next = declareLayer(map, { id: id.value, name: name.value, rank: rank.value });
81
157
  if (!next.ok)
82
158
  return err({ kind: 'invariant-violation', path, violation: next.error });
83
159
  map = next.value;
84
160
  }
85
- for (const [i, rawLane] of asArray(raw['lanes']).entries()) {
161
+ for (const [i, rawLane] of lanes.value.entries()) {
162
+ const where = `lanes[${i}]`;
86
163
  if (!isRecord(rawLane))
87
- return err({ kind: 'bad-shape', path, detail: `lanes[${i}] is not an object` });
88
- const id = makeLaneId(String(rawLane['id'] ?? ''));
164
+ return badShape(path, where, 'an object', rawLane);
165
+ const rawId = requiredString(rawLane, 'id', where, path);
166
+ if (!rawId.ok)
167
+ return rawId;
168
+ const id = makeLaneId(rawId.value);
89
169
  if (!id.ok)
90
170
  return err({ kind: 'invariant-violation', path, violation: id.error });
91
- const label = optionalString(rawLane['label']);
92
- if (label === undefined)
93
- return err({ kind: 'bad-shape', path, detail: `lanes[${i}] needs a string label` });
94
- const declared = declareLane(map, { id: id.value, label });
171
+ const label = requiredString(rawLane, 'label', where, path);
172
+ if (!label.ok)
173
+ return label;
174
+ const declared = declareLane(map, { id: id.value, label: label.value });
95
175
  if (!declared.ok)
96
176
  return err({ kind: 'invariant-violation', path, violation: declared.error });
97
177
  map = declared.value;
98
178
  }
99
- for (const [i, rawGroup] of asArray(raw['groups']).entries()) {
179
+ for (const [i, rawGroup] of groups.value.entries()) {
180
+ const where = `groups[${i}]`;
100
181
  if (!isRecord(rawGroup))
101
- return err({ kind: 'bad-shape', path, detail: `groups[${i}] is not an object` });
102
- const id = makeGroupId(String(rawGroup['id'] ?? ''));
182
+ return badShape(path, where, 'an object', rawGroup);
183
+ const rawId = requiredString(rawGroup, 'id', where, path);
184
+ if (!rawId.ok)
185
+ return rawId;
186
+ const id = makeGroupId(rawId.value);
103
187
  if (!id.ok)
104
188
  return err({ kind: 'invariant-violation', path, violation: id.error });
105
- const layer = makeLayerId(String(rawGroup['layer'] ?? ''));
189
+ const rawLayer = requiredString(rawGroup, 'layer', where, path);
190
+ if (!rawLayer.ok)
191
+ return rawLayer;
192
+ const layer = makeLayerId(rawLayer.value);
106
193
  if (!layer.ok)
107
194
  return err({ kind: 'invariant-violation', path, violation: layer.error });
108
- const label = optionalString(rawGroup['label']);
109
- if (label === undefined)
110
- return err({ kind: 'bad-shape', path, detail: `groups[${i}] needs a string label` });
111
- const declared = declareGroup(map, { id: id.value, label, layer: layer.value });
195
+ const label = requiredString(rawGroup, 'label', where, path);
196
+ if (!label.ok)
197
+ return label;
198
+ const declared = declareGroup(map, { id: id.value, label: label.value, layer: layer.value });
112
199
  if (!declared.ok)
113
200
  return err({ kind: 'invariant-violation', path, violation: declared.error });
114
201
  map = declared.value;
115
202
  }
116
- for (const [i, rawNode] of asArray(raw['nodes']).entries()) {
203
+ for (const [i, rawNode] of nodes.value.entries()) {
204
+ const where = `nodes[${i}]`;
117
205
  if (!isRecord(rawNode))
118
- return err({ kind: 'bad-shape', path, detail: `nodes[${i}] is not an object` });
119
- const id = makeNodeId(String(rawNode['id'] ?? ''));
206
+ return badShape(path, where, 'an object', rawNode);
207
+ const rawId = requiredString(rawNode, 'id', where, path);
208
+ if (!rawId.ok)
209
+ return rawId;
210
+ const id = makeNodeId(rawId.value);
120
211
  if (!id.ok)
121
212
  return err({ kind: 'invariant-violation', path, violation: id.error });
122
- const layer = makeLayerId(String(rawNode['layer'] ?? ''));
213
+ const rawLayer = requiredString(rawNode, 'layer', where, path);
214
+ if (!rawLayer.ok)
215
+ return rawLayer;
216
+ const layer = makeLayerId(rawLayer.value);
123
217
  if (!layer.ok)
124
218
  return err({ kind: 'invariant-violation', path, violation: layer.error });
125
- const status = makeNodeStatus(String(rawNode['status'] ?? ''));
219
+ const rawStatus = requiredString(rawNode, 'status', where, path);
220
+ if (!rawStatus.ok)
221
+ return rawStatus;
222
+ const status = makeNodeStatus(rawStatus.value);
126
223
  if (!status.ok)
127
224
  return err({ kind: 'invariant-violation', path, violation: status.error });
128
- const label = optionalString(rawNode['label']);
129
- if (label === undefined)
130
- return err({ kind: 'bad-shape', path, detail: `nodes[${i}] needs a string label` });
131
- const detail = optionalString(rawNode['detail']);
132
- const rawGroup = optionalString(rawNode['group']);
225
+ const label = requiredString(rawNode, 'label', where, path);
226
+ if (!label.ok)
227
+ return label;
228
+ const detail = optionalString(rawNode, 'detail', where, path);
229
+ if (!detail.ok)
230
+ return detail;
231
+ const rawGroup = optionalString(rawNode, 'group', where, path);
232
+ if (!rawGroup.ok)
233
+ return rawGroup;
133
234
  let group;
134
- if (rawGroup !== undefined) {
135
- const made = makeGroupId(rawGroup);
235
+ if (rawGroup.value !== undefined) {
236
+ const made = makeGroupId(rawGroup.value);
136
237
  if (!made.ok)
137
238
  return err({ kind: 'invariant-violation', path, violation: made.error });
138
239
  group = made.value;
139
240
  }
140
- const rawNodeKind = optionalString(rawNode['kind']);
241
+ const rawNodeKind = optionalString(rawNode, 'kind', where, path);
242
+ if (!rawNodeKind.ok)
243
+ return rawNodeKind;
141
244
  let nodeKind;
142
- if (rawNodeKind !== undefined) {
143
- const made = makeNodeKind(rawNodeKind);
245
+ if (rawNodeKind.value !== undefined) {
246
+ const made = makeNodeKind(rawNodeKind.value);
144
247
  if (!made.ok)
145
248
  return err({ kind: 'invariant-violation', path, violation: made.error });
146
249
  nodeKind = made.value;
147
250
  }
148
- const rawLane = optionalString(rawNode['lane']);
251
+ const rawLane = optionalString(rawNode, 'lane', where, path);
252
+ if (!rawLane.ok)
253
+ return rawLane;
149
254
  let lane;
150
- if (rawLane !== undefined) {
151
- const made = makeLaneId(rawLane);
255
+ if (rawLane.value !== undefined) {
256
+ const made = makeLaneId(rawLane.value);
152
257
  if (!made.ok)
153
258
  return err({ kind: 'invariant-violation', path, violation: made.error });
154
259
  lane = made.value;
155
260
  }
156
- const rawSubmap = optionalString(rawNode['submap']);
261
+ const rawSubmap = optionalString(rawNode, 'submap', where, path);
262
+ if (!rawSubmap.ok)
263
+ return rawSubmap;
157
264
  let submap;
158
- if (rawSubmap !== undefined) {
159
- const made = makeSubmapRef(rawSubmap);
265
+ if (rawSubmap.value !== undefined) {
266
+ const made = makeSubmapRef(rawSubmap.value);
160
267
  if (!made.ok)
161
268
  return err({ kind: 'invariant-violation', path, violation: made.error });
162
269
  submap = made.value;
163
270
  }
164
271
  const declared = declareNode(map, {
165
272
  id: id.value,
166
- label,
273
+ label: label.value,
167
274
  layer: layer.value,
168
275
  status: status.value,
169
- ...(detail !== undefined ? { detail } : {}),
276
+ ...(detail.value !== undefined ? { detail: detail.value } : {}),
170
277
  ...(group !== undefined ? { group } : {}),
171
278
  ...(nodeKind !== undefined ? { kind: nodeKind } : {}),
172
279
  ...(lane !== undefined ? { lane } : {}),
@@ -175,24 +282,36 @@ export function parseMap(raw, path) {
175
282
  if (!declared.ok)
176
283
  return err({ kind: 'invariant-violation', path, violation: declared.error });
177
284
  map = declared.value;
178
- const evidence = optionalString(rawNode['evidence']);
179
- if (evidence !== undefined) {
180
- const updated = updateNode(map, { id: id.value, evidence });
285
+ const evidence = optionalString(rawNode, 'evidence', where, path);
286
+ if (!evidence.ok)
287
+ return evidence;
288
+ if (evidence.value !== undefined) {
289
+ const updated = updateNode(map, { id: id.value, evidence: evidence.value });
181
290
  if (!updated.ok)
182
291
  return err({ kind: 'invariant-violation', path, violation: updated.error });
183
292
  map = updated.value;
184
293
  }
185
294
  }
186
- for (const [i, rawEdge] of asArray(raw['edges']).entries()) {
295
+ for (const [i, rawEdge] of edges.value.entries()) {
296
+ const where = `edges[${i}]`;
187
297
  if (!isRecord(rawEdge))
188
- return err({ kind: 'bad-shape', path, detail: `edges[${i}] is not an object` });
189
- const from = makeNodeId(String(rawEdge['from'] ?? ''));
298
+ return badShape(path, where, 'an object', rawEdge);
299
+ const rawFrom = requiredString(rawEdge, 'from', where, path);
300
+ if (!rawFrom.ok)
301
+ return rawFrom;
302
+ const from = makeNodeId(rawFrom.value);
190
303
  if (!from.ok)
191
304
  return err({ kind: 'invariant-violation', path, violation: from.error });
192
- const to = makeNodeId(String(rawEdge['to'] ?? ''));
305
+ const rawTo = requiredString(rawEdge, 'to', where, path);
306
+ if (!rawTo.ok)
307
+ return rawTo;
308
+ const to = makeNodeId(rawTo.value);
193
309
  if (!to.ok)
194
310
  return err({ kind: 'invariant-violation', path, violation: to.error });
195
- const linked = linkNodes(map, from.value, to.value, optionalString(rawEdge['label']));
311
+ const label = optionalString(rawEdge, 'label', where, path);
312
+ if (!label.ok)
313
+ return label;
314
+ const linked = linkNodes(map, from.value, to.value, label.value);
196
315
  if (!linked.ok)
197
316
  return err({ kind: 'invariant-violation', path, violation: linked.error });
198
317
  map = linked.value;
@@ -2,18 +2,39 @@
2
2
  * Layer 1b — Node-side persistence for a MellosMap.
3
3
  *
4
4
  * The state file IS the event bus of the whole plugin: the MCP server writes
5
- * it, the terminal watcher polls it. The file FORMAT (version, page-id
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
8
  * grammar, parse/serialize with boundary validation) lives in ./format.ts,
7
9
  * pure of I/O so browsers can consume it; this module owns everything that
8
10
  * touches the filesystem, and one promise:
9
11
  *
10
12
  * P2. Writes are atomic: a reader polling the file either sees the previous
11
13
  * complete map or the new complete map, never a torn write. Achieved by
12
- * writing a sibling temp file and renaming it over the target.
14
+ * writing a PRIVATE sibling temp file and renaming it over the target.
13
15
  *
14
- * Expected failures (missing file, malformed JSON, invariant violations) are
15
- * Result values. Only truly unexpected I/O faults (permissions, disk) are
16
- * allowed to propagate as exceptions.
16
+ * The concurrency model P2 buys, stated plainly:
17
+ * - Several writers may target one project at once. Each save is atomic and
18
+ * lands whole, so a reader never sees half a map — but there is NO
19
+ * lost-update protection: two saves of the same page race, and the last
20
+ * rename wins, silently discarding what the other writer computed from an
21
+ * older read. Pages are the isolation unit (one effort = one page); two
22
+ * sessions that must not clobber each other belong on two pages.
23
+ * - The temp file carries the writer's pid and a random suffix, so
24
+ * concurrent writers never share one and never install each other's
25
+ * half-written content.
26
+ * - A rename can transiently fail while a reader holds the target open
27
+ * (EPERM/EBUSY on Windows), so it is retried with a short backoff before
28
+ * the save is reported as failed.
29
+ * - A page can also be DELETED (deletePageFile). Deletion races a writer
30
+ * the same way a save does, and the WRITER WINS: a save landing after it
31
+ * recreates the page. Stated at the function, not defended against.
32
+ *
33
+ * Expected failures (missing file, malformed JSON, invariant violations, a
34
+ * write that would not land) are Result values. A failed save changed
35
+ * nothing: the previous file content is intact and the caller may retry.
36
+ * Only truly unexpected I/O faults on the READ path are allowed to propagate
37
+ * as exceptions.
17
38
  *
18
39
  * Node consumers import everything from here; the format surface is
19
40
  * re-exported so persistence has one import site per runtime.
@@ -22,12 +43,14 @@ import { type MellosMap, type Result } from '../domain/types.js';
22
43
  import { type PageId, type StoreError } from './format.js';
23
44
  export { STATE_FILE_VERSION, type PageId, makePageId, type StoreError, describeStoreError, parseMap, serializeMap, } from './format.js';
24
45
  /**
25
- * Project-relative location of the DEFAULT page's state file. The store lives
26
- * in the tool-owned `.mellos/` directory: the map belongs to mellos-mapping,
27
- * not to whichever host (Claude Code, Codex, a harness) happens to drive the
28
- * server, so no host brand appears in the path. Pre-0.19 stores under
29
- * `.claude/` are moved once by {@link migrateLegacyStore}.
46
+ * The directory a store lives in, under a project root or under a user's home.
47
+ * It is tool-owned: the map belongs to mellos-mapping, not to whichever host
48
+ * (Claude Code, Codex, a harness) happens to drive the server, so no host
49
+ * brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
50
+ * by {@link migrateLegacyStore}.
30
51
  */
52
+ export declare const STORE_DIR_NAME = ".mellos";
53
+ /** Project-relative location of the DEFAULT page's state file. */
31
54
  export declare const STATE_FILE_RELATIVE_PATH: string;
32
55
  /** Directory (next to the default file) holding the named pages. */
33
56
  export declare const PAGES_DIR_NAME = "pages";
@@ -37,6 +60,31 @@ export declare function pageFilePath(defaultFile: string, page?: PageId): string
37
60
  export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
38
61
  /** Existing page files: the default page first (when present), then named pages sorted by slug. */
39
62
  export declare function listPageFiles(defaultFile: string): string[];
63
+ /**
64
+ * Delete one page's file — a named page, or the DEFAULT page (whose file is
65
+ * optional by design, so removing it is a legal state, not a mutilation).
66
+ *
67
+ * Preconditions: none. Postcondition on ok: no file at `path` — an already
68
+ * absent one is ok too, because the goal state is what is promised, not the
69
+ * act. Postcondition on error: the file is still there and the caller may
70
+ * retry or report; the errno is carried in the detail.
71
+ *
72
+ * Concurrency, stated plainly: deletion races a concurrent writer and THE
73
+ * WRITER WINS. A server saving that page while this runs simply recreates the
74
+ * file (its rename is atomic and needs no existing target), so the page comes
75
+ * back. That is accepted rather than defended against — the store has no
76
+ * lost-update protection anywhere (see the module header), and locking one
77
+ * operation would only make the race rarer, never absent, while claiming
78
+ * otherwise. Pages are the isolation unit: nobody deletes a page another
79
+ * session is writing.
80
+ *
81
+ * What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
82
+ * siblings. Those temps are private to a save IN FLIGHT, and a live writer
83
+ * whose temp vanished would fail its rename — turning a harmless leftover
84
+ * into a broken save. A stray temp only exists when a write failed AND its
85
+ * own cleanup failed; it is inert, and the README documents it.
86
+ */
87
+ export declare function deletePageFile(path: string): Result<void, StoreError>;
40
88
  /** Sibling of the default file carrying a one-shot "show this page" request. */
41
89
  export declare const FOCUS_FILE_NAME = "focus";
42
90
  export declare function focusFilePath(defaultFile: string): string;
@@ -50,11 +98,125 @@ export interface FocusRequest {
50
98
  * request; the channel is best-effort and junk is swept by the same delete.
51
99
  */
52
100
  export declare function takeFocusRequest(defaultFile: string): FocusRequest | undefined;
53
- /** Sibling of the default file holding the project's plugin configuration. */
101
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
102
+ export declare const QUIT_FILE_NAME = "quit";
103
+ export declare function quitFilePath(defaultFile: string): string;
104
+ /**
105
+ * Consume a pending quit request: read it, delete the file, say whether there
106
+ * was one. Absent file — the overwhelmingly common case — or content that is
107
+ * not a JSON object means NO request; the channel is best-effort and junk is
108
+ * swept by the same delete.
109
+ *
110
+ * The empty JSON object is the whole grammar. It exists so that a stray file
111
+ * of this name — an editor backup, a half-written write from a foreign tool —
112
+ * cannot take a live pane down by accident; a pane closing is the one thing
113
+ * in this channel a user cannot undo by waiting.
114
+ */
115
+ export declare function takeQuitRequest(defaultFile: string): boolean;
116
+ /**
117
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
118
+ * leftover rather than as a message.
119
+ *
120
+ * A toggle that wrote the request and then lost its watcher (a crash, a
121
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
122
+ * open would consume it on its first tick and close instantly. The watcher
123
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
124
+ * have been addressed to it. Best-effort, like every delete in this channel.
125
+ */
126
+ export declare function sweepQuitRequest(defaultFile: string): void;
127
+ /** Directory (next to the default file) holding one report per live pane. */
128
+ export declare const VIEWERS_DIR_NAME = "viewers";
129
+ /** On-disk report format version. Bump only with a documented migration. */
130
+ export declare const VIEWER_FILE_VERSION = 1;
131
+ /**
132
+ * How often a pane refreshes its report — the pane's timer, and the unit the
133
+ * two thresholds below are counted in.
134
+ */
135
+ export declare const VIEWER_HEARTBEAT_MS = 1000;
136
+ /**
137
+ * A report older than this is not a pane, it is a pane's remains. Five missed
138
+ * heartbeats: long enough to survive a garbage collection, a slow repaint or
139
+ * a busy disk, short enough that a closed pane is known closed before anyone
140
+ * asks twice.
141
+ */
142
+ export declare const VIEWER_STALE_MS = 5000;
143
+ /**
144
+ * A report this old is deleted on sight by whoever reads it. A pane that was
145
+ * killed leaves its file behind forever, and a file that outlives every pane
146
+ * would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
147
+ * slack: a machine that just woke from sleep has stale reports whose panes
148
+ * are alive and about to beat again, and there is no reason to make them pay
149
+ * for the sleep with a deleted file.
150
+ */
151
+ export declare const VIEWER_SWEEP_MS = 60000;
152
+ /** Where a store's viewer reports live, given the default page's file path. */
153
+ export declare function viewersDirPath(defaultFile: string): string;
154
+ /** Where ONE pane's report lives. The pid in the name is the pane's identity. */
155
+ export declare function viewerFilePath(defaultFile: string, pid: number): string;
156
+ /** What a pane says about itself while it is up. */
157
+ export interface ViewerReport {
158
+ /** The page on screen right now; undefined = the default page. */
159
+ readonly page: PageId | undefined;
160
+ /** Auto-follow: whether the pane will switch to whatever page is written next. */
161
+ readonly follow: boolean;
162
+ }
163
+ /** A report fresh enough to be a pane, with whose it is and how old. */
164
+ export interface LiveViewer extends ViewerReport {
165
+ readonly pid: number;
166
+ /** Age of the report in ms — how long ago that pane last said anything. */
167
+ readonly ageMs: number;
168
+ }
169
+ /**
170
+ * Publish this pane's report, atomically (P2) — a reader polling the file
171
+ * sees the previous whole report or the new one, never half of one.
172
+ *
173
+ * Preconditions: none; the viewers directory is created if missing.
174
+ * Postcondition on ok: the pane's file holds `report` and its mtime is now,
175
+ * which is what says the pane is alive. On error nothing about the pane is
176
+ * claimed, and the next heartbeat is the whole recovery — so a caller reports
177
+ * a failed beat at most once and keeps beating.
178
+ */
179
+ export declare function publishViewer(defaultFile: string, pid: number, report: ViewerReport): Result<void, StoreError>;
180
+ /**
181
+ * Take this pane's report back — the pane is going away and says so, rather
182
+ * than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
183
+ *
184
+ * Best-effort by contract: an exit path is the worst place to raise, and a
185
+ * report nobody could delete goes stale on its own within seconds.
186
+ */
187
+ export declare function retireViewer(defaultFile: string, pid: number): void;
188
+ /**
189
+ * Every pane currently showing this store, youngest report first.
190
+ *
191
+ * @param nowMs - the caller's clock (Date.now()), passed in so the ageing
192
+ * rules can be tested without waiting for real seconds to pass.
193
+ * @returns the live reports; an EMPTY array means nobody is seeing this map.
194
+ *
195
+ * Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
196
+ * pane that would have refreshed it is provably gone and no other code runs
197
+ * often enough to notice. A report between stale and sweep is ignored but
198
+ * kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
199
+ * report is: a viewer started by hand counts exactly like one the launcher
200
+ * opened, because the user can see both.
201
+ */
202
+ export declare function readLiveViewers(defaultFile: string, nowMs: number): readonly LiveViewer[];
203
+ /** Name of the file holding a mapping-policy configuration, in either scope. */
54
204
  export declare const CONFIG_FILE_NAME = "config.json";
55
205
  /** On-disk config format version. Bump only with a documented migration. */
56
206
  export declare const CONFIG_FILE_VERSION = 1;
207
+ /** The PROJECT-scope configuration file: sibling of the default page. */
57
208
  export declare function configFilePath(defaultFile: string): string;
209
+ /**
210
+ * The USER-scope configuration file: the same store directory name, under the
211
+ * user's own base directory.
212
+ *
213
+ * @param userBase - the user's home directory. Always passed in, never read
214
+ * from the environment down here: a function that reached for os.homedir()
215
+ * itself would make every spec a gamble on the developer's real
216
+ * configuration, and one of them would eventually write it. Entry points
217
+ * resolve the home once and hand it down.
218
+ */
219
+ export declare function userConfigFilePath(userBase: string): string;
58
220
  export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
59
221
  /** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
60
222
  export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
@@ -67,13 +229,49 @@ export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, In
67
229
  /** One line of meaning per policy — the wording every surface repeats. */
68
230
  export declare function describeMappingPolicy(policy: MappingPolicy): string;
69
231
  /**
70
- * The configured policy, or ok(undefined) when the project has never been
71
- * set up (missing file or missing key — both mean "nobody chose yet").
232
+ * The policy recorded in ONE configuration file, or ok(undefined) when nobody
233
+ * has chosen there (missing file or missing key — both mean the same thing).
72
234
  * A file that exists but does not parse is an error, never silently ignored.
235
+ *
236
+ * @param path - the configuration file itself: {@link configFilePath} for a
237
+ * project, {@link userConfigFilePath} for the user. One loader, two scopes.
238
+ */
239
+ export declare function loadMappingPolicy(path: string): Result<MappingPolicy | undefined, StoreError>;
240
+ /**
241
+ * Persist the policy atomically (P2), same write as the map files.
242
+ * @param path - the configuration file to write; see {@link loadMappingPolicy}.
243
+ * @returns ok when the file holds the policy; save-failed leaves the previous
244
+ * configuration in place.
245
+ */
246
+ export declare function saveMappingPolicy(path: string, policy: MappingPolicy): Result<void, StoreError>;
247
+ /** The two places a mapping policy can be recorded, in override order. */
248
+ export declare const POLICY_SCOPES: readonly ["user", "project"];
249
+ export type PolicyScope = (typeof POLICY_SCOPES)[number];
250
+ /** What both scopes say, and which of them actually governs. */
251
+ export interface MappingPolicyScopes {
252
+ readonly project: MappingPolicy | undefined;
253
+ readonly user: MappingPolicy | undefined;
254
+ /** What to act on. undefined = nobody has chosen yet, anywhere. */
255
+ readonly effective: MappingPolicy | undefined;
256
+ /** Where `effective` came from; undefined exactly when `effective` is. */
257
+ readonly source: PolicyScope | undefined;
258
+ }
259
+ /**
260
+ * Resolve the policy that governs this project: the PROJECT file if it names
261
+ * one, otherwise the USER file, otherwise nothing.
262
+ *
263
+ * Both scopes are reported, not just the winner — a surface that says "always"
264
+ * without saying where it came from cannot tell a user why changing their
265
+ * user-level choice did nothing here.
266
+ *
267
+ * @param projectConfigFile - see {@link configFilePath}.
268
+ * @param userConfigFile - see {@link userConfigFilePath}.
269
+ * @returns err as soon as EITHER file exists and is broken, project first: a
270
+ * configuration nobody can read is not the same as a configuration nobody
271
+ * wrote, and silently falling through to the other scope would act on a
272
+ * choice the user did not make.
73
273
  */
74
- export declare function loadMappingPolicy(defaultFile: string): Result<MappingPolicy | undefined, StoreError>;
75
- /** Persist the policy atomically (P2), same temp-and-rename as the map files. */
76
- export declare function saveMappingPolicy(defaultFile: string, policy: MappingPolicy): void;
274
+ export declare function effectiveMappingPolicy(projectConfigFile: string, userConfigFile: string): Result<MappingPolicyScopes, StoreError>;
77
275
  /** Project-relative location of the pre-0.20 default page file. */
78
276
  export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
79
277
  /**
@@ -89,8 +287,10 @@ export declare function migrateLegacyStore(defaultFile: string): boolean;
89
287
  /** Load and validate the map file at `path`. */
90
288
  export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
91
289
  /**
92
- * Write the map to `path` atomically (P2): serialize to `<path>.tmp` in the
93
- * same directory, then rename over the target. Creates the parent directory
94
- * if missing.
290
+ * Write the map to `path` atomically (P2): serialize to a private sibling
291
+ * temp file, then rename it over the target, retrying a rename the OS
292
+ * refuses transiently. Creates the parent directory if missing.
293
+ * @returns ok when the file holds the new map; save-failed when it does not,
294
+ * in which case the previous content is intact and the call may be retried.
95
295
  */
96
- export declare function saveMapFile(path: string, map: MellosMap): void;
296
+ export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;