simt-dev 0.6.0 → 0.6.3

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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
@@ -0,0 +1,326 @@
1
+ // The write journal and `undo` (plan 0003 R49).
2
+ //
3
+ // Every repo-file write a command performs records {path, beforeDigest,
4
+ // afterDigest, beforeBytes}, grouped per command run as one set in
5
+ // `.simt/journal/`. `undo` restores the last set, but only when every file in
6
+ // it is untouched since — a file whose current bytes no longer match its
7
+ // afterDigest refuses the whole undo, naming the file, and nothing is restored.
8
+ //
9
+ // Three deliberate boundaries, each a failure this module refuses to be:
10
+ //
11
+ // - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
12
+ // rename (the same stale spelling survives in bench/probes.mjs comments).
13
+ // The journal lives under STATE_DIR like every other per-repo state.
14
+ // - Nothing under `.simt/` is journaled. The journal recording itself is
15
+ // recursion; the ledger, index and candidates are evidence and caches, and
16
+ // restoring those to an earlier byte-shape would corrupt R17 rather than
17
+ // repair anything. `journaledWriteFile` performs those writes unrecorded.
18
+ // - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
19
+ // the record shape is file bytes — pointer changes are reported by the
20
+ // command as before and are not undone. Removals are journaled as entries
21
+ // with afterDigest null (the run deleted the path; undo re-creates it).
22
+ // Writes outside the repository (`shared install` into $HOME, `plugin`
23
+ // into --out) are never journaled: undo must not reach past the repo root.
24
+ // Commands that span the boundary (`shared install` edits the repo manifest
25
+ // as part of a $HOME write) and multi-root runs (`bench`, `compare` write
26
+ // into several repos at once) are excluded as a unit — a set belongs to one
27
+ // repo, and restoring half a command would lie about the other half.
28
+ //
29
+ // A set file is written atomically (tmp + rename) and only when non-empty, so
30
+ // the last set on disk is always the last run that changed something. An undone
31
+ // set is renamed to `.undone.json` rather than deleted: the history survives,
32
+ // a second `undo` naturally targets the set before it, and the untouched-check
33
+ // refuses when the two sets overlap on a file the first undo already restored.
34
+
35
+ import { createHash } from 'node:crypto';
36
+ import { cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
37
+ import { dirname, join, relative, sep } from 'node:path';
38
+
39
+ import { STATE_DIR } from './name.mjs';
40
+
41
+ const JOURNAL_DIR = join(STATE_DIR, 'journal');
42
+ const SET_SUFFIX = '.json';
43
+ const UNDONE_SUFFIX = '.undone.json';
44
+
45
+ function digest(bytes) {
46
+ return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
47
+ }
48
+
49
+ function rel(root, path) {
50
+ return relative(root, path).split(sep).join('/');
51
+ }
52
+
53
+ /**
54
+ * Read the current bytes of a path, or null when it does not exist. Any other
55
+ * failure propagates: an unreadable file is fail-closed, not treated as absent.
56
+ */
57
+ async function readOrNull(path) {
58
+ try {
59
+ return await readFile(path);
60
+ } catch (error) {
61
+ if (error.code === 'ENOENT') return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write a repo file and record the write in the run's set.
68
+ *
69
+ * @param {string} root the repository root; paths outside it are refused
70
+ * @param {Array} acc the run's set, created by the caller and committed once
71
+ * @param {string} path absolute path to write
72
+ * @param {string} text the bytes to write
73
+ */
74
+ export async function journaledWriteFile(root, acc, path, text) {
75
+ if (relative(root, path).startsWith('..')) {
76
+ throw new Error(`journal refuses a write outside the repository: ${path}`);
77
+ }
78
+ const before = await readOrNull(path);
79
+ // Internal state is written, not recorded — see the module header.
80
+ const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
81
+ await mkdir(dirname(path), { recursive: true });
82
+ await writeFile(path, text, 'utf8');
83
+ if (!internal) {
84
+ acc.push({
85
+ path: rel(root, path),
86
+ beforeDigest: before === null ? null : digest(before),
87
+ afterDigest: digest(Buffer.from(text, 'utf8')),
88
+ beforeBytes: before === null ? null : before.toString('utf8'),
89
+ });
90
+ }
91
+ }
92
+
93
+ /** List every regular file under a directory, absolute paths. Missing dir → []. */
94
+ async function treeFiles(dir) {
95
+ let st;
96
+ try {
97
+ st = await stat(dir);
98
+ } catch (error) {
99
+ if (error.code === 'ENOENT') return [];
100
+ throw error;
101
+ }
102
+ if (!st.isDirectory()) return [dir];
103
+ const out = [];
104
+ for (const name of await readdir(dir)) {
105
+ out.push(...(await treeFiles(join(dir, name))));
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Copy a skill source tree into the repo and record every file landed, so
112
+ * `undo` removes what the copy created and restores what it overwrote. The
113
+ * before-snapshot is taken first: a `cp` that overwrites in place would
114
+ * otherwise destroy the bytes the entry needs.
115
+ */
116
+ export async function journaledCopyDir(root, acc, src, dest) {
117
+ if (relative(root, dest).startsWith('..')) {
118
+ throw new Error(`journal refuses a copy outside the repository: ${dest}`);
119
+ }
120
+ const before = new Map();
121
+ for (const abs of await treeFiles(dest)) {
122
+ before.set(rel(root, abs), await readOrNull(abs));
123
+ }
124
+ await mkdir(dirname(dest), { recursive: true });
125
+ await cp(src, dest, { recursive: true });
126
+ for (const abs of await treeFiles(dest)) {
127
+ const path = rel(root, abs);
128
+ const prev = before.get(path) ?? null;
129
+ before.delete(path);
130
+ const after = await readOrNull(abs);
131
+ if (prev !== null && after !== null && prev.equals(after)) continue;
132
+ acc.push({
133
+ path,
134
+ beforeDigest: prev === null ? null : digest(prev),
135
+ afterDigest: digest(after),
136
+ beforeBytes: prev === null ? null : prev.toString('utf8'),
137
+ });
138
+ }
139
+ // Files the copy removed (an overwrite that drops a name) are removals.
140
+ for (const [path, prev] of before) {
141
+ acc.push({
142
+ path,
143
+ beforeDigest: digest(prev),
144
+ afterDigest: null,
145
+ beforeBytes: prev.toString('utf8'),
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Remove a repo file and record the removal: undo re-creates the path with its
152
+ * pre-run bytes, provided nothing has appeared there since. Removing what is
153
+ * already absent records nothing — there is no state to restore.
154
+ */
155
+ export async function journaledRemoveFile(root, acc, path) {
156
+ if (relative(root, path).startsWith('..')) {
157
+ throw new Error(`journal refuses a removal outside the repository: ${path}`);
158
+ }
159
+ const before = await readOrNull(path);
160
+ if (before === null) return;
161
+ const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
162
+ await unlink(path);
163
+ if (!internal) {
164
+ acc.push({
165
+ path: rel(root, path),
166
+ beforeDigest: digest(before),
167
+ afterDigest: null,
168
+ beforeBytes: before.toString('utf8'),
169
+ });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Add the journal directory to .gitignore, mirroring `ignoreIdentity`: match on
175
+ * the exact line, never append a duplicate. Called by commitJournal so every
176
+ * writer command is covered at once rather than each one remembering.
177
+ * Exported so `doctor --fix` repairs a missing entry with the same bytes a
178
+ * writer would have left (plan 0003 R47).
179
+ */
180
+ /**
181
+ * The .gitignore bytes `ignoreJournal` would write for `text`, or null when
182
+ * `text` already ignores the journal directory. One owner for the line
183
+ * spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
184
+ * there, and `doctor --fix --diff` previews it — all through this function so
185
+ * the three cannot disagree about what counts as ignored (plan 0003 R50).
186
+ */
187
+ export function journalIgnoreNext(text) {
188
+ const line = `${JOURNAL_DIR}/`;
189
+ if (text.split('\n').map((l) => l.trim()).includes(line)) return null;
190
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
191
+ return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
192
+ }
193
+
194
+ export async function ignoreJournal(root, journal = null) {
195
+ const ignorePath = join(root, '.gitignore');
196
+ let text;
197
+ try {
198
+ text = await readFile(ignorePath, 'utf8');
199
+ } catch {
200
+ text = '';
201
+ }
202
+ const next = journalIgnoreNext(text);
203
+ if (next === null) return;
204
+ // A caller-supplied journal records the edit so `undo` restores it (R47);
205
+ // without one the write stays unrecorded, as `commitJournal` needs it.
206
+ if (journal) await journaledWriteFile(root, journal, ignorePath, next);
207
+ else await writeFile(ignorePath, next);
208
+ }
209
+
210
+ function setName(command, at) {
211
+ const stamp = new Date(at).toISOString().replace(/[:.]/g, '-');
212
+ return `${stamp}-${command}-${process.pid}.json`;
213
+ }
214
+
215
+ /**
216
+ * Persist a run's set. Writes nothing when the run changed nothing, so the
217
+ * last set on disk is always the last run that wrote.
218
+ *
219
+ * @returns {Promise<string|null>} the set id (file name), or null
220
+ */
221
+ export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
222
+ if (acc.length === 0) return null;
223
+ await ignoreJournal(root);
224
+ const dir = join(root, JOURNAL_DIR);
225
+ await mkdir(dir, { recursive: true });
226
+ const id = setName(command, at);
227
+ const body = `${JSON.stringify({ version: 1, command, at, entries: acc }, null, 2)}\n`;
228
+ const tmp = join(dir, `${id}.tmp-${process.pid}`);
229
+ await writeFile(tmp, body, 'utf8');
230
+ await rename(tmp, join(dir, id));
231
+ return id;
232
+ }
233
+
234
+ /**
235
+ * The `journal-unignored` finding's facts (plan 0003 R47): the sets on disk and
236
+ * whether .gitignore names the directory. One owner for the line spelling —
237
+ * `ignoreJournal` writes it, this reads it — so the two cannot disagree about
238
+ * what counts as ignored.
239
+ */
240
+ export async function journalHygiene(root) {
241
+ const sets = await listJournalSets(root);
242
+ if (sets.length === 0) return { sets, ignored: true };
243
+ let text;
244
+ try {
245
+ text = await readFile(join(root, '.gitignore'), 'utf8');
246
+ } catch {
247
+ text = '';
248
+ }
249
+ const line = `${JOURNAL_DIR}/`;
250
+ return { sets, ignored: text.split('\n').map((l) => l.trim()).includes(line) };
251
+ }
252
+
253
+ /** Set ids newest-first, undone sets excluded. */
254
+ export async function listJournalSets(root) {
255
+ let names;
256
+ try {
257
+ names = await readdir(join(root, JOURNAL_DIR));
258
+ } catch (error) {
259
+ if (error.code === 'ENOENT') return [];
260
+ throw error;
261
+ }
262
+ // An undone set ends in `.undone.json`, which itself ends in `.json` — the
263
+ // suffix test alone would list history as current and `undo` would restore
264
+ // the same set twice. Exclude the undone first.
265
+ return names.filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX)).sort().reverse();
266
+ }
267
+
268
+ export async function readJournalSet(root, id) {
269
+ const raw = await readFile(join(root, JOURNAL_DIR, id), 'utf8');
270
+ return JSON.parse(raw);
271
+ }
272
+
273
+ /**
274
+ * Restore one set: verify every file is untouched since, then restore all.
275
+ * Verification precedes any restore, so a refusal changes nothing.
276
+ *
277
+ * @returns {Promise<{restored: string[]}|{refused: {path: string, detail: string}}>}
278
+ */
279
+ async function undoJournalSet(root, id) {
280
+ const set = await readJournalSet(root, id);
281
+ for (const entry of set.entries) {
282
+ const abs = join(root, entry.path);
283
+ const bytes = await readOrNull(abs);
284
+ const now = bytes === null ? null : digest(bytes);
285
+ if (now !== entry.afterDigest) {
286
+ return {
287
+ refused: {
288
+ path: entry.path,
289
+ detail:
290
+ bytes === null
291
+ ? 'the file was deleted after the journaled write'
292
+ : 'the file changed after the journaled write',
293
+ },
294
+ };
295
+ }
296
+ }
297
+ const restored = [];
298
+ for (const entry of set.entries) {
299
+ const abs = join(root, entry.path);
300
+ if (entry.afterDigest === null) {
301
+ // The run removed this path: re-create it with its pre-run bytes.
302
+ await mkdir(dirname(abs), { recursive: true });
303
+ await writeFile(abs, entry.beforeBytes, 'utf8');
304
+ } else if (entry.beforeBytes === null) {
305
+ // The run created this path: remove it.
306
+ await unlink(abs);
307
+ } else {
308
+ await writeFile(abs, entry.beforeBytes, 'utf8');
309
+ }
310
+ restored.push(entry.path);
311
+ }
312
+ await rename(join(root, JOURNAL_DIR, id), join(root, JOURNAL_DIR, id.replace(/\.json$/, UNDONE_SUFFIX)));
313
+ return { restored };
314
+ }
315
+
316
+ /**
317
+ * Restore the most recent set that has not been undone.
318
+ *
319
+ * @returns {Promise<{empty: true}|{set: string, restored: string[]}|{set: string, refused: {path: string, detail: string}}>}
320
+ */
321
+ export async function undoLastJournalSet(root) {
322
+ const [id] = await listJournalSets(root);
323
+ if (!id) return { empty: true };
324
+ const result = await undoJournalSet(root, id);
325
+ return { set: id, ...result };
326
+ }
package/src/lease.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // File leases: one path, one session, for the length of a TTL (plan 0003 R54,
2
+ // ADR-101).
3
+ //
4
+ // The data is deliberately dumb: one JSON file beside the index, absolute expiry
5
+ // timestamps, no lock. Sessions claim paths with `simt lease <paths>` and the
6
+ // pre-edit hook reads the same file to refuse an edit from anyone who is not the
7
+ // holder. Two writers racing loses one write rather than corrupting the file, and a
8
+ // lost write is a claim the next run re-makes — anything stronger would be a
9
+ // coordination system built around a file that a TTL already cleans up.
10
+ //
11
+ // What is NOT tolerated is an unreadable file: `readLeases` reports that as an error
12
+ // rather than answering "no leases", because a claim file that stopped parsing is the
13
+ // one state in which silently forgetting every holder would hand a routing-around
14
+ // path to the session that broke it. Which way that fails is the caller's decision —
15
+ // the hook refuses edits, the command refuses to write (ADR-101 rulings 4).
16
+
17
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
18
+ import { dirname, join, relative, resolve, sep } from 'node:path';
19
+
20
+ import { STATE_DIR } from './name.mjs';
21
+
22
+ /** Where the claims live, in every repository: beside the index and the ledger. */
23
+ export const LEASE_FILE = join(STATE_DIR, 'leases.json');
24
+
25
+ /** The schema the file is written with; a reader that does not know it refuses. */
26
+ const LEASE_SCHEMA = 1;
27
+
28
+ /**
29
+ * How long a claim lives when the caller does not say.
30
+ *
31
+ * An hour: longer than one focused task step, short enough that a session which
32
+ * crashed mid-edit does not hold a path for the rest of the day.
33
+ */
34
+ export const DEFAULT_TTL_SECONDS = 3600;
35
+
36
+ /**
37
+ * The repository-relative name of a path this tool may lease, or null when it is not
38
+ * inside the repository at all.
39
+ *
40
+ * Relative to the root and slash-normalised, because that is the spelling the file
41
+ * holds and the one a payload's absolute `file_path` has to become before the two can
42
+ * be compared. The root itself leases nothing — there is no edit that "is" the
43
+ * repository — so it answers null like a path that escapes it.
44
+ */
45
+ export function leasePathFor(root, input) {
46
+ const rel = relative(resolve(root), resolve(root, String(input ?? '')));
47
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`)) return null;
48
+ return rel.split(sep).join('/');
49
+ }
50
+
51
+ /**
52
+ * Read the claims.
53
+ *
54
+ * Missing is `{ leases: [] }` — a repository that never leased anything is the
55
+ * default state, not a failure. Everything else (permissions, a half-written file, a
56
+ * hand edit that no longer parses) comes back as `error` with no leases, and the
57
+ * caller decides which way that fails.
58
+ *
59
+ * @returns {Promise<{path: string, leases: object[], error: string|null}>}
60
+ */
61
+ export async function readLeases(root) {
62
+ const path = join(root, LEASE_FILE);
63
+ let text;
64
+ try {
65
+ text = await readFile(path, 'utf8');
66
+ } catch (error) {
67
+ if (error.code === 'ENOENT') return { path, leases: [], error: null };
68
+ return { path, leases: [], error: `${LEASE_FILE} could not be read (${error.message})` };
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(text);
72
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.leases)) {
73
+ return { path, leases: [], error: `${LEASE_FILE} is not a lease document (no leases array)` };
74
+ }
75
+ // A malformed entry is dropped rather than trusted: an expiry that is not a number
76
+ // would otherwise live forever, which is the opposite of what a TTL promises.
77
+ const leases = parsed.leases.filter(
78
+ (l) => l !== null && typeof l === 'object' && typeof l.path === 'string' &&
79
+ typeof l.session === 'string' && Number.isFinite(l.expiresAt),
80
+ );
81
+ return { path, leases, error: null };
82
+ } catch (error) {
83
+ return { path, leases: [], error: `${LEASE_FILE} is not valid JSON (${error.message})` };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The claims still live at `now`, sorted by path so two reads of the same file
89
+ * answer in the same order.
90
+ *
91
+ * Expiry is evaluated by the reader, never swept: the hook that decides an edit is
92
+ * the moment a TTL has to be honoured, and a sweeper would be a second process whose
93
+ * absence would otherwise change nothing.
94
+ */
95
+ export function activeLeases({ leases } = { leases: [] }, now = Date.now()) {
96
+ return leases.filter((l) => l.expiresAt > now).sort((a, b) => a.path.localeCompare(b.path));
97
+ }
98
+
99
+ /**
100
+ * Replace the claim file wholesale, sorted by path for deterministic bytes.
101
+ *
102
+ * Not journaled for `undo`: the file is local coordination state like the check
103
+ * cache, and restoring yesterday's holders would undo claims other sessions are
104
+ * relying on right now.
105
+ */
106
+ export async function writeLeases(root, leases) {
107
+ const path = join(root, LEASE_FILE);
108
+ const sorted = [...leases].sort((a, b) => a.path.localeCompare(b.path));
109
+ const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: sorted }, null, 2)}\n`;
110
+ await mkdir(dirname(path), { recursive: true });
111
+ await writeFile(path, body, 'utf8');
112
+ return path;
113
+ }
@@ -387,12 +387,26 @@ export async function serve(tools, { input = process.stdin, output = process.std
387
387
  for await (const line of rl) {
388
388
  if (line.trim() === '') continue;
389
389
 
390
+ // Parse and handling fail differently and must answer differently. A line that is
391
+ // not JSON has no id to answer to, so it is a parse error with `id: null`; a
392
+ // throw inside `handle` belongs to the request it was answering, so it keeps
393
+ // that request's id — answering it as a parse error hangs the client that is
394
+ // still waiting for its answer (Q12).
390
395
  let response;
396
+ let message;
391
397
  try {
392
- response = await handle(JSON.parse(line), tools, { packageVersion, state });
398
+ message = JSON.parse(line);
393
399
  } catch (parseFailure) {
394
400
  response = error(null, RPC.PARSE, `invalid JSON: ${parseFailure.message}`);
395
401
  }
402
+ if (response === undefined) {
403
+ try {
404
+ response = await handle(message, tools, { packageVersion, state });
405
+ } catch (handlerFailure) {
406
+ const id = message !== null && typeof message === 'object' && 'id' in message ? message.id : null;
407
+ response = error(id, RPC.INTERNAL, `internal error: ${handlerFailure?.message ?? handlerFailure}`);
408
+ }
409
+ }
396
410
  if (response === null) continue;
397
411
  output.write(`${JSON.stringify(response)}\n`);
398
412
  }