@frmwrkd/snag-mcp 0.1.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/LICENSE +19 -0
- package/README.md +187 -0
- package/dist/client.d.ts +544 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1074 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/resources.d.ts +19 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +281 -0
- package/dist/resources.js.map +1 -0
- package/dist/tokens.d.ts +69 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +90 -0
- package/dist/tokens.js.map +1 -0
- package/dist/tools.d.ts +115 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +868 -0
- package/dist/tools.js.map +1 -0
- package/package.json +44 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to `snagd`, and reading `.snag/` when `snagd` is not there.
|
|
3
|
+
*
|
|
4
|
+
* ## Two sources, deliberately
|
|
5
|
+
*
|
|
6
|
+
* CLAUDE.md non-negotiable 3: **`.snag/` files are the API.** SQLite is a
|
|
7
|
+
* rebuildable index and MCP is "for actions and filtered queries, never bulk
|
|
8
|
+
* transfer". That cuts two ways here:
|
|
9
|
+
*
|
|
10
|
+
* - **Reads prefer the daemon** (`GET /tasks`, `GET /plan`, `GET /map`),
|
|
11
|
+
* because the SQLite index carries live status, and `.snag/plan.json` is a
|
|
12
|
+
* snapshot whose `status` fields go stale the moment a task is claimed.
|
|
13
|
+
* - **Reads fall back to `.snag/` on disk** when the daemon is unreachable, so
|
|
14
|
+
* a developer with no daemon running still gets a queue instead of an error.
|
|
15
|
+
* The fallback is labelled `source: 'files'` in every response — a stale
|
|
16
|
+
* status the agent knows is stale is useful; one it thinks is live is not.
|
|
17
|
+
* - **Writes require the daemon.** `snag_claim` and `snag_resolve` change
|
|
18
|
+
* status, and status changes have to go through doc 02 §5.3's transition
|
|
19
|
+
* table, the markdown writer and the event log all at once. Writing
|
|
20
|
+
* `.snag/tasks/*.md` from here would race the daemon's file watcher and
|
|
21
|
+
* desynchronise the index. When the daemon is down these tools fail with an
|
|
22
|
+
* actionable error naming the exact command that fixes it.
|
|
23
|
+
*
|
|
24
|
+
* ## Port discovery
|
|
25
|
+
*
|
|
26
|
+
* doc 02 §7.3: the daemon binds the first free port in **7654–7664** and writes
|
|
27
|
+
* it to `.snag/.port`. **"Hard-coding 7654 anywhere will strand the user the
|
|
28
|
+
* first time anything else takes that port."** Unlike the extension, this
|
|
29
|
+
* process has a filesystem, so it reads `.snag/.port` first and only probes the
|
|
30
|
+
* range if that file is missing or points at a dead port.
|
|
31
|
+
*
|
|
32
|
+
* ## Which snagd
|
|
33
|
+
*
|
|
34
|
+
* One snagd per checkout, all sharing 7654–7664 and one token, so the first
|
|
35
|
+
* daemon to answer is not necessarily ours. `GET /health` reports the `root` it
|
|
36
|
+
* serves; a daemon for the same root is taken at once. A daemon for another
|
|
37
|
+
* root is still ours when both roots are checkouts of **one repository** — a
|
|
38
|
+
* `git worktree` next to the main checkout shares its git common dir — but only
|
|
39
|
+
* if no daemon for the exact root answered. Anything else is skipped, and
|
|
40
|
+
* remembered, so a write fails with "snagd on :7654 serves <that root>" rather
|
|
41
|
+
* than the misleading "snagd is not running".
|
|
42
|
+
*
|
|
43
|
+
* Before every write the cached handle's `/health` is asked again: a snagd that
|
|
44
|
+
* restarted may have left its port to another project's daemon, and a status
|
|
45
|
+
* change through that port would move someone else's task.
|
|
46
|
+
*
|
|
47
|
+
* ## Auth
|
|
48
|
+
*
|
|
49
|
+
* doc 02 §7.1: every route except `GET /health` requires the token from
|
|
50
|
+
* `~/.snag/token`, presented as `Authorization: Bearer <token>`.
|
|
51
|
+
*/
|
|
52
|
+
import { execFile } from 'node:child_process';
|
|
53
|
+
import fs from 'node:fs';
|
|
54
|
+
import os from 'node:os';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
import { fileURLToPath } from 'node:url';
|
|
57
|
+
import { PlanSchema, RepoMapSchema, SnagConfigSchema, TaskSchema, defaultConfig, taskFileName, } from '@frmwrkd/snag-core';
|
|
58
|
+
/* --------------------------------------------------------------- constants */
|
|
59
|
+
export const DAEMON_HOST = '127.0.0.1';
|
|
60
|
+
/** doc 02 §7.3 — the probe range, inclusive at both ends. */
|
|
61
|
+
export const DAEMON_PORT_START = 7654;
|
|
62
|
+
export const DAEMON_PORT_END = 7664;
|
|
63
|
+
/** `.snag/` — the contract directory (doc 02 §6.1). */
|
|
64
|
+
export const SNAG_DIR = '.snag';
|
|
65
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
66
|
+
const PROBE_TIMEOUT_MS = 400;
|
|
67
|
+
/**
|
|
68
|
+
* `git rev-parse` answers in tens of milliseconds. It runs only when a snagd
|
|
69
|
+
* serves a different root, and a hung git (a network drive, a credential
|
|
70
|
+
* prompt) must not hold up a tool call for longer than this.
|
|
71
|
+
*/
|
|
72
|
+
const GIT_TIMEOUT_MS = 1_500;
|
|
73
|
+
/* ------------------------------------------------------------------ errors */
|
|
74
|
+
/**
|
|
75
|
+
* The command that starts snagd, for whoever is running *this* build.
|
|
76
|
+
*
|
|
77
|
+
* `snagd` is not published, so `npx snagd` cannot start it today. Until it is, the MCP
|
|
78
|
+
* server runs from a Snag checkout (`scripts/install-claude-code.mjs`), and
|
|
79
|
+
* the daemon's build sits beside it at `apps/daemon/dist/cli.js` — so when that
|
|
80
|
+
* file exists, name it. `npx snagd` remains the answer for a published install.
|
|
81
|
+
*/
|
|
82
|
+
export function snagdCommand(moduleUrl = import.meta.url) {
|
|
83
|
+
try {
|
|
84
|
+
const cli = fileURLToPath(new URL('../../daemon/dist/cli.js', moduleUrl));
|
|
85
|
+
if (fs.existsSync(cli))
|
|
86
|
+
return `node "${cli.split(path.sep).join('/')}"`;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* not a file: URL — fall through */
|
|
90
|
+
}
|
|
91
|
+
return 'npx snagd';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The daemon could not be reached at all. The message is written to be read by
|
|
95
|
+
* an agent: it names the command that fixes it and the files that work without
|
|
96
|
+
* it.
|
|
97
|
+
*/
|
|
98
|
+
export class DaemonUnavailableError extends Error {
|
|
99
|
+
root;
|
|
100
|
+
/** The underlying failure (`fetch failed`, a timeout), kept so it can be re-thrown against the project root. */
|
|
101
|
+
detail;
|
|
102
|
+
constructor(root, detail) {
|
|
103
|
+
super(`snagd is not running (probed http://${DAEMON_HOST}:${DAEMON_PORT_START}-${DAEMON_PORT_END} and ${path.join(root, SNAG_DIR, '.port')})` +
|
|
104
|
+
`${detail === undefined ? '' : `: ${detail}`}. ` +
|
|
105
|
+
`Start it with \`${snagdCommand()}\` in ${root}. ` +
|
|
106
|
+
`Until then the queue is still readable without me — Read/Grep ${SNAG_DIR}/tasks/*.md and ${SNAG_DIR}/plan.json.`);
|
|
107
|
+
this.name = 'DaemonUnavailableError';
|
|
108
|
+
this.root = root;
|
|
109
|
+
this.detail = detail;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** A path as a shell argument: quoted when it has whitespace (`L:\Open Source\app`). */
|
|
113
|
+
function shellPath(p) {
|
|
114
|
+
return /\s/.test(p) ? `"${p}"` : p;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A snagd answered — for a different checkout.
|
|
118
|
+
*
|
|
119
|
+
* Reported instead of "snagd is not running", which is false and sends the
|
|
120
|
+
* agent to start a second daemon that then fights the first for the port
|
|
121
|
+
* range. A subclass of `DaemonUnavailableError` because it means the same thing
|
|
122
|
+
* to every caller: there is no daemon *for this project* to write through.
|
|
123
|
+
*/
|
|
124
|
+
export class DaemonRootMismatchError extends DaemonUnavailableError {
|
|
125
|
+
/** The port the other daemon answered on, or `null` if the URL had none. */
|
|
126
|
+
port;
|
|
127
|
+
/** The root that daemon serves, exactly as its `/health` reported it. */
|
|
128
|
+
daemonRoot;
|
|
129
|
+
constructor(root, daemonRoot, baseUrl) {
|
|
130
|
+
super(root);
|
|
131
|
+
let port = null;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = Number.parseInt(new URL(baseUrl).port, 10);
|
|
134
|
+
port = Number.isInteger(parsed) ? parsed : null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* not a URL — leave the port unnamed */
|
|
138
|
+
}
|
|
139
|
+
this.name = 'DaemonRootMismatchError';
|
|
140
|
+
this.port = port;
|
|
141
|
+
this.daemonRoot = daemonRoot;
|
|
142
|
+
this.message =
|
|
143
|
+
`snagd on :${port ?? '?'} serves ${daemonRoot}, not ${root} — ` +
|
|
144
|
+
`open Claude Code in ${daemonRoot}, or start snagd with --root ${shellPath(root)}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The daemon answered, and said no. `code` is its machine-readable error id. */
|
|
148
|
+
export class DaemonRequestError extends Error {
|
|
149
|
+
status;
|
|
150
|
+
code;
|
|
151
|
+
constructor(status, code, message) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.name = 'DaemonRequestError';
|
|
154
|
+
this.status = status;
|
|
155
|
+
this.code = code;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const globalFetch = () => {
|
|
159
|
+
const f = globalThis.fetch;
|
|
160
|
+
if (typeof f !== 'function') {
|
|
161
|
+
throw new Error('global fetch is unavailable — @frmwrkd/snag-mcp requires Node 22+');
|
|
162
|
+
}
|
|
163
|
+
return f;
|
|
164
|
+
};
|
|
165
|
+
/* ------------------------------------------------------------- discovery */
|
|
166
|
+
/**
|
|
167
|
+
* Whether two paths name the same directory.
|
|
168
|
+
*
|
|
169
|
+
* `path.resolve` on both, case-folded on win32 — NTFS is case-insensitive, and
|
|
170
|
+
* `git rev-parse --show-toplevel` (which is where snagd's `root` comes from)
|
|
171
|
+
* and `process.cwd()` do not agree on drive-letter case. The realpath
|
|
172
|
+
* comparison is the fallback for symlinked temp directories (macOS `/var` →
|
|
173
|
+
* `/private/var`) and 8.3 short names.
|
|
174
|
+
*/
|
|
175
|
+
export function samePath(a, b) {
|
|
176
|
+
const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
177
|
+
if (fold(path.resolve(a)) === fold(path.resolve(b)))
|
|
178
|
+
return true;
|
|
179
|
+
try {
|
|
180
|
+
return fold(fs.realpathSync.native(a)) === fold(fs.realpathSync.native(b));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** `git` from `PATH`, bounded by `GIT_TIMEOUT_MS`. Rejects when git is missing. */
|
|
187
|
+
export const runGit = (args, cwd) => new Promise((resolve, reject) => {
|
|
188
|
+
execFile('git', [...args], { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true, encoding: 'utf8' }, (error, stdout) => (error === null ? resolve(stdout) : reject(error)));
|
|
189
|
+
});
|
|
190
|
+
/**
|
|
191
|
+
* `git rev-parse --git-common-dir`, resolved against `dir` and cached.
|
|
192
|
+
*
|
|
193
|
+
* The common dir is what a linked worktree shares with the checkout it was
|
|
194
|
+
* added from (`git worktree add`), so two roots with the same common dir are
|
|
195
|
+
* one repository and one queue. git prints it relative to the cwd in a main
|
|
196
|
+
* checkout (`.git`) and absolute in a linked worktree, hence the resolve.
|
|
197
|
+
* `--path-format=absolute` would save that, but needs git 2.31.
|
|
198
|
+
*
|
|
199
|
+
* Only answers are cached. A failure — git not installed, not a repository, a
|
|
200
|
+
* timeout — returns `null`, which only ever means "not proven to be the same
|
|
201
|
+
* repository", and is asked again next time: `git init` can happen mid-session.
|
|
202
|
+
*/
|
|
203
|
+
export function createGitCommonDirLookup(run = runGit) {
|
|
204
|
+
const cache = new Map();
|
|
205
|
+
return async (dir) => {
|
|
206
|
+
const resolved = path.resolve(dir);
|
|
207
|
+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
208
|
+
const hit = cache.get(key);
|
|
209
|
+
if (hit !== undefined)
|
|
210
|
+
return hit;
|
|
211
|
+
let out;
|
|
212
|
+
try {
|
|
213
|
+
out = (await run(['rev-parse', '--git-common-dir'], resolved)).trim();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (out.length === 0)
|
|
219
|
+
return null;
|
|
220
|
+
const common = path.resolve(resolved, out);
|
|
221
|
+
cache.set(key, common);
|
|
222
|
+
return common;
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** The process-wide lookup, so the cache outlives any one discovery. */
|
|
226
|
+
export const defaultGitCommonDir = createGitCommonDirLookup();
|
|
227
|
+
/** Whether `a` and `b` are checkouts of one repository (same git common dir). */
|
|
228
|
+
export async function sameRepository(a, b, lookup = defaultGitCommonDir) {
|
|
229
|
+
const [left, right] = await Promise.all([lookup(a), lookup(b)]);
|
|
230
|
+
return left !== null && right !== null && samePath(left, right);
|
|
231
|
+
}
|
|
232
|
+
function homeDir() {
|
|
233
|
+
try {
|
|
234
|
+
return os.homedir();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Walk up from `startDir` looking for a directory containing `.snag/`.
|
|
242
|
+
*
|
|
243
|
+
* Claude Code spawns a stdio MCP server with the project directory as its cwd,
|
|
244
|
+
* so this normally hits on the first try. Walking up covers the case where the
|
|
245
|
+
* server is spawned from a subdirectory of the repo.
|
|
246
|
+
*
|
|
247
|
+
* **`~/.snag` is not a project.** It is snagd's token directory (doc 02 §7.1),
|
|
248
|
+
* so every directory under the user's home has a `.snag/` somewhere above it.
|
|
249
|
+
* Without this skip, an MCP server started in an app that has never run snagd
|
|
250
|
+
* adopts the home directory as its root, and every read and write goes to the
|
|
251
|
+
* wrong place with no error at all.
|
|
252
|
+
*/
|
|
253
|
+
export function findProjectRoot(startDir = process.cwd(), home = homeDir()) {
|
|
254
|
+
let current = path.resolve(startDir);
|
|
255
|
+
for (;;) {
|
|
256
|
+
if (fs.existsSync(path.join(current, SNAG_DIR)) &&
|
|
257
|
+
(home === null || !samePath(current, home))) {
|
|
258
|
+
return current;
|
|
259
|
+
}
|
|
260
|
+
const parent = path.dirname(current);
|
|
261
|
+
if (parent === current)
|
|
262
|
+
return null;
|
|
263
|
+
current = parent;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** `~/.snag/token`, or `null` on first run before `snagd` has generated one. */
|
|
267
|
+
export function readToken(home = os.homedir()) {
|
|
268
|
+
const file = path.join(home, SNAG_DIR, 'token');
|
|
269
|
+
try {
|
|
270
|
+
const contents = fs.readFileSync(file, 'utf8').trim();
|
|
271
|
+
return contents.length > 0 ? contents : null;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** `.snag/.port` — the port `snagd` actually bound (doc 02 §7.3). */
|
|
278
|
+
export function readPortFile(root) {
|
|
279
|
+
try {
|
|
280
|
+
const raw = fs.readFileSync(path.join(root, SNAG_DIR, '.port'), 'utf8').trim();
|
|
281
|
+
const port = Number.parseInt(raw, 10);
|
|
282
|
+
return Number.isInteger(port) && port > 0 && port < 65_536 ? port : null;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The `.snag/` half of the contract. Pure reads — nothing here ever writes,
|
|
290
|
+
* because the daemon owns the directory and watches it.
|
|
291
|
+
*/
|
|
292
|
+
export class FileStore {
|
|
293
|
+
root;
|
|
294
|
+
constructor(root) {
|
|
295
|
+
this.root = path.resolve(root);
|
|
296
|
+
}
|
|
297
|
+
get dir() {
|
|
298
|
+
return path.join(this.root, SNAG_DIR);
|
|
299
|
+
}
|
|
300
|
+
/** Absolute path for a repo-relative path, or `null` if it escapes the root. */
|
|
301
|
+
resolveInsideRoot(relative) {
|
|
302
|
+
const absolute = path.resolve(this.root, relative);
|
|
303
|
+
const prefix = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
|
|
304
|
+
return absolute === this.root || absolute.startsWith(prefix) ? absolute : null;
|
|
305
|
+
}
|
|
306
|
+
readJson(file) {
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** `.snag/config.json`. Falls back to `defaultConfig()` — it is committed, but optional. */
|
|
315
|
+
readConfig() {
|
|
316
|
+
const parsed = SnagConfigSchema.safeParse(this.readJson(path.join(this.dir, 'config.json')));
|
|
317
|
+
return parsed.success ? parsed.data : defaultConfig();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* `.snag/plan.json`, distinguishing "no plan yet" from "the plan is broken".
|
|
321
|
+
*
|
|
322
|
+
* Collapsing both to `null` is how 53 unreadable tasks came out of `snag_list`
|
|
323
|
+
* as "the queue is clear" — an agent was told there was no work when in fact
|
|
324
|
+
* the work could not be read. A file that exists and fails validation must
|
|
325
|
+
* never be reported as absence.
|
|
326
|
+
*/
|
|
327
|
+
readPlanResult() {
|
|
328
|
+
const file = path.join(this.dir, 'plan.json');
|
|
329
|
+
if (!fs.existsSync(file))
|
|
330
|
+
return { state: 'missing' };
|
|
331
|
+
const raw = this.readJson(file);
|
|
332
|
+
if (raw === null) {
|
|
333
|
+
return { state: 'invalid', issues: ['plan.json is not readable as JSON'] };
|
|
334
|
+
}
|
|
335
|
+
const parsed = PlanSchema.safeParse(raw);
|
|
336
|
+
if (parsed.success)
|
|
337
|
+
return { state: 'ok', plan: parsed.data };
|
|
338
|
+
return {
|
|
339
|
+
state: 'invalid',
|
|
340
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** `.snag/plan.json`, or `null` when there is no *usable* plan. */
|
|
344
|
+
readPlan() {
|
|
345
|
+
const result = this.readPlanResult();
|
|
346
|
+
return result.state === 'ok' ? result.plan : null;
|
|
347
|
+
}
|
|
348
|
+
/** `.snag/map.json`, or `null` when the repo map has not been built. */
|
|
349
|
+
readMap() {
|
|
350
|
+
const parsed = RepoMapSchema.safeParse(this.readJson(path.join(this.dir, 'map.json')));
|
|
351
|
+
return parsed.success ? parsed.data : null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Tasks from `.snag/plan.json`. This is the offline fallback for
|
|
355
|
+
* `snag_list`/`snag_get`: `plan.tasks` is plain JSON validated by
|
|
356
|
+
* `TaskSchema`, so no YAML parser is needed to read the queue without the
|
|
357
|
+
* daemon. Statuses here are a snapshot and may lag the index.
|
|
358
|
+
*/
|
|
359
|
+
tasks() {
|
|
360
|
+
const plan = this.readPlan();
|
|
361
|
+
if (plan === null)
|
|
362
|
+
return [];
|
|
363
|
+
return plan.tasks;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Why the offline task list is empty, when it is empty. `null` means it is
|
|
367
|
+
* not empty, or is legitimately so.
|
|
368
|
+
*
|
|
369
|
+
* `tasks()` has to keep returning `Task[]` — every caller expects an array —
|
|
370
|
+
* so the reason travels alongside it rather than inside it.
|
|
371
|
+
*/
|
|
372
|
+
emptyTasksReason() {
|
|
373
|
+
const result = this.readPlanResult();
|
|
374
|
+
if (result.state === 'ok')
|
|
375
|
+
return null;
|
|
376
|
+
if (result.state === 'missing')
|
|
377
|
+
return null;
|
|
378
|
+
const shown = result.issues.slice(0, 8);
|
|
379
|
+
const more = result.issues.length - shown.length;
|
|
380
|
+
return (`.snag/plan.json exists but does not match @frmwrkd/snag-core's schema, so no tasks could be read ` +
|
|
381
|
+
`(${result.issues.length} problem(s)):\n` +
|
|
382
|
+
shown.map((issue) => ` - ${issue}`).join('\n') +
|
|
383
|
+
(more > 0 ? `\n … and ${more} more` : '') +
|
|
384
|
+
'\nThis is a broken plan, not an empty queue. Regenerate it, or fix the file.');
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The task's markdown file, repo-relative, or `null` if it is not on disk.
|
|
388
|
+
* The name is derived by `taskFileName` from `@frmwrkd/snag-core`, which is the same
|
|
389
|
+
* function the daemon's writer uses, so the two cannot drift.
|
|
390
|
+
*/
|
|
391
|
+
taskMarkdownPath(task) {
|
|
392
|
+
const relative = path.posix.join(SNAG_DIR, 'tasks', taskFileName(task.id, task.title));
|
|
393
|
+
const absolute = this.resolveInsideRoot(relative);
|
|
394
|
+
if (absolute === null || !fs.existsSync(absolute))
|
|
395
|
+
return null;
|
|
396
|
+
return relative;
|
|
397
|
+
}
|
|
398
|
+
readTextFile(relative) {
|
|
399
|
+
const absolute = this.resolveInsideRoot(relative);
|
|
400
|
+
if (absolute === null)
|
|
401
|
+
return null;
|
|
402
|
+
try {
|
|
403
|
+
return fs.readFileSync(absolute, 'utf8');
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
readBytes(relative) {
|
|
410
|
+
const absolute = this.resolveInsideRoot(relative);
|
|
411
|
+
if (absolute === null)
|
|
412
|
+
return null;
|
|
413
|
+
try {
|
|
414
|
+
return fs.readFileSync(absolute);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
export class DaemonClient {
|
|
422
|
+
baseUrl;
|
|
423
|
+
token;
|
|
424
|
+
fetchImpl;
|
|
425
|
+
timeoutMs;
|
|
426
|
+
constructor(options) {
|
|
427
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
428
|
+
this.token = options.token;
|
|
429
|
+
this.fetchImpl = options.fetchImpl ?? globalFetch();
|
|
430
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
431
|
+
}
|
|
432
|
+
headers() {
|
|
433
|
+
const headers = { accept: 'application/json' };
|
|
434
|
+
if (this.token !== null)
|
|
435
|
+
headers['authorization'] = `Bearer ${this.token}`;
|
|
436
|
+
return headers;
|
|
437
|
+
}
|
|
438
|
+
async raw(method, pathname, body, timeoutMs) {
|
|
439
|
+
const headers = this.headers();
|
|
440
|
+
if (body !== undefined)
|
|
441
|
+
headers['content-type'] = 'application/json';
|
|
442
|
+
return this.fetchImpl(`${this.baseUrl}${pathname}`, {
|
|
443
|
+
method,
|
|
444
|
+
headers,
|
|
445
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
446
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* A JSON request. Throws `DaemonRequestError` on a non-2xx, preserving the
|
|
451
|
+
* daemon's own `error` code and `message` — those messages are written to be
|
|
452
|
+
* actionable (`verify_only`, `illegal_transition`, …) and paraphrasing them
|
|
453
|
+
* would throw away the only explanation the agent gets.
|
|
454
|
+
*/
|
|
455
|
+
async request(method, pathname, body) {
|
|
456
|
+
let response;
|
|
457
|
+
try {
|
|
458
|
+
response = await this.raw(method, pathname, body);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
throw new DaemonUnavailableError(this.baseUrl, error instanceof Error ? error.message : String(error));
|
|
462
|
+
}
|
|
463
|
+
const text = await response.text();
|
|
464
|
+
if (!response.ok) {
|
|
465
|
+
let code = `http_${response.status}`;
|
|
466
|
+
let message = text;
|
|
467
|
+
try {
|
|
468
|
+
const parsed = JSON.parse(text);
|
|
469
|
+
if (typeof parsed.error === 'string')
|
|
470
|
+
code = parsed.error;
|
|
471
|
+
if (typeof parsed.message === 'string')
|
|
472
|
+
message = parsed.message;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
/* leave the raw body as the message */
|
|
476
|
+
}
|
|
477
|
+
throw new DaemonRequestError(response.status, code, message);
|
|
478
|
+
}
|
|
479
|
+
return JSON.parse(text);
|
|
480
|
+
}
|
|
481
|
+
health() {
|
|
482
|
+
return this.request('GET', '/health');
|
|
483
|
+
}
|
|
484
|
+
async listTasks(query = {}) {
|
|
485
|
+
const search = new URLSearchParams();
|
|
486
|
+
if (query.status !== undefined)
|
|
487
|
+
search.set('status', query.status);
|
|
488
|
+
if (query.wave !== undefined)
|
|
489
|
+
search.set('wave', String(query.wave));
|
|
490
|
+
if (query.severity !== undefined)
|
|
491
|
+
search.set('severity', query.severity);
|
|
492
|
+
if (query.limit !== undefined)
|
|
493
|
+
search.set('limit', String(query.limit));
|
|
494
|
+
const suffix = search.size > 0 ? `?${search.toString()}` : '';
|
|
495
|
+
const body = await this.request('GET', `/tasks${suffix}`);
|
|
496
|
+
return parseTasks(body.tasks);
|
|
497
|
+
}
|
|
498
|
+
async getTask(id) {
|
|
499
|
+
try {
|
|
500
|
+
const body = await this.request('GET', `/tasks/${encodeURIComponent(id)}`);
|
|
501
|
+
const task = TaskSchema.parse(body.task);
|
|
502
|
+
return { task, file: typeof body.file === 'string' ? body.file : null };
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (error instanceof DaemonRequestError && error.status === 404)
|
|
506
|
+
return null;
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* `POST /git/commit` — snagd stages the named files and commits them.
|
|
512
|
+
*
|
|
513
|
+
* The daemon does it, not this server: it owns the project root and
|
|
514
|
+
* `.snag/config.json`'s `git` block, and one implementation means the rails
|
|
515
|
+
* (named paths only, never a push, never a protected branch) cannot differ
|
|
516
|
+
* between an MCP call and the commit at the end of a headless run.
|
|
517
|
+
*/
|
|
518
|
+
async commit(body) {
|
|
519
|
+
return this.request('POST', '/git/commit', body);
|
|
520
|
+
}
|
|
521
|
+
async setStatus(id, status) {
|
|
522
|
+
const body = await this.request('PATCH', `/tasks/${encodeURIComponent(id)}`, {
|
|
523
|
+
status,
|
|
524
|
+
actor: 'agent',
|
|
525
|
+
});
|
|
526
|
+
return TaskSchema.parse(body.task);
|
|
527
|
+
}
|
|
528
|
+
async plan() {
|
|
529
|
+
try {
|
|
530
|
+
const body = await this.request('GET', '/plan');
|
|
531
|
+
return PlanSchema.parse(body.plan);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (error instanceof DaemonRequestError && error.status === 404)
|
|
535
|
+
return null;
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async map() {
|
|
540
|
+
try {
|
|
541
|
+
const body = await this.request('GET', '/map');
|
|
542
|
+
return RepoMapSchema.parse(body.map);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
if (error instanceof DaemonRequestError && error.status === 404)
|
|
546
|
+
return null;
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* `POST /verify/:taskId` with `dry_run: true` — the daemon's preflight and
|
|
552
|
+
* nothing else. It starts no run and changes no status, so it can be asked
|
|
553
|
+
* *before* `snag_resolve` moves the task: a task that Verify can never judge
|
|
554
|
+
* must not be parked in `verifying`, where nothing will ever move it again.
|
|
555
|
+
*
|
|
556
|
+
* The refusals are the daemon's own, verbatim: 404 `not_found`, 409
|
|
557
|
+
* `verify_disabled`, 409 `no_capture_url`, 503 `verify_unavailable`.
|
|
558
|
+
*/
|
|
559
|
+
async verifyPreflight(taskId) {
|
|
560
|
+
try {
|
|
561
|
+
await this.request('POST', `/verify/${encodeURIComponent(taskId)}`, {
|
|
562
|
+
dry_run: true,
|
|
563
|
+
});
|
|
564
|
+
return { ok: true };
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (error instanceof DaemonRequestError) {
|
|
568
|
+
return { ok: false, status: error.status, code: error.code, message: error.message };
|
|
569
|
+
}
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* `GET /verify/:taskId` — whether a run is in flight, and how the last one
|
|
575
|
+
* ended. The daemon keeps only the last report per task, in memory, so
|
|
576
|
+
* `last: null` after a snagd restart means "not since it started", not
|
|
577
|
+
* "never". `null` when the daemon has no such task, or no such route.
|
|
578
|
+
*/
|
|
579
|
+
async verifyStatus(taskId) {
|
|
580
|
+
let body;
|
|
581
|
+
try {
|
|
582
|
+
body = await this.request('GET', `/verify/${encodeURIComponent(taskId)}`);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (error instanceof DaemonRequestError && error.status === 404)
|
|
586
|
+
return null;
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
const last = typeof body.last === 'object' && body.last !== null
|
|
590
|
+
? body.last
|
|
591
|
+
: null;
|
|
592
|
+
return {
|
|
593
|
+
running: body.running === true,
|
|
594
|
+
outcome: typeof last?.outcome === 'string' ? last.outcome : null,
|
|
595
|
+
reason: typeof last?.reason === 'string' ? last.reason : null,
|
|
596
|
+
last_at: typeof body.last_at === 'string' ? body.last_at : null,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* `POST /verify/:taskId` (doc 02 §7.1 / §10). Returns a result rather than
|
|
601
|
+
* throwing when the daemon refuses: a task that reached `verifying` but could
|
|
602
|
+
* not queue a run is a partial success, and reporting it as a total failure
|
|
603
|
+
* would make the agent retry a transition that already happened.
|
|
604
|
+
*
|
|
605
|
+
* The daemon answers **202 `{ queued, task_uid, already_running }`** and runs
|
|
606
|
+
* the loop in the background — Verify drives a real Chrome tab, which takes
|
|
607
|
+
* far longer than this client's timeout, so waiting on it here would turn
|
|
608
|
+
* every resolve into a timeout. `wait: true` is never sent.
|
|
609
|
+
*
|
|
610
|
+
* **503 `verify_unavailable`** means no extension is attached. The daemon's
|
|
611
|
+
* message says exactly how to fix that, so it is passed through verbatim.
|
|
612
|
+
*
|
|
613
|
+
* `DaemonUnavailableError` propagates: the caller owns the connection cache
|
|
614
|
+
* and has to forget a daemon that stopped answering.
|
|
615
|
+
*/
|
|
616
|
+
async queueVerify(taskId, body) {
|
|
617
|
+
let response;
|
|
618
|
+
try {
|
|
619
|
+
response = await this.request('POST', `/verify/${encodeURIComponent(taskId)}`, body);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (error instanceof DaemonRequestError) {
|
|
623
|
+
return {
|
|
624
|
+
queued: false,
|
|
625
|
+
code: error.code,
|
|
626
|
+
detail: error.code === 'verify_unavailable'
|
|
627
|
+
? error.message
|
|
628
|
+
: `snagd declined the verify run (${error.code}: ${error.message})`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
if (response.queued === true) {
|
|
634
|
+
const alreadyRunning = response.already_running === true;
|
|
635
|
+
return {
|
|
636
|
+
queued: true,
|
|
637
|
+
already_running: alreadyRunning,
|
|
638
|
+
detail: alreadyRunning
|
|
639
|
+
? `A verify run for this task was already in progress, so no second one was started. ${VERIFY_QUEUED_DETAIL}`
|
|
640
|
+
: VERIFY_QUEUED_DETAIL,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
// A daemon that ran the loop synchronously (`200 { report }`). The run is
|
|
644
|
+
// already over, so the task's status has moved on from what we hold.
|
|
645
|
+
const outcome = response.report?.outcome;
|
|
646
|
+
return {
|
|
647
|
+
queued: true,
|
|
648
|
+
detail: typeof outcome === 'string'
|
|
649
|
+
? `Verify already ran (outcome: ${outcome}); snag_get the task for its current status`
|
|
650
|
+
: VERIFY_QUEUED_DETAIL,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* doc 02 §10 as it runs today. No pixel comparison happens yet, so this must
|
|
656
|
+
* not claim one: a capture with no console error gives Verify nothing to judge
|
|
657
|
+
* by, and the task goes to `needs_review` rather than to `resolved`.
|
|
658
|
+
*/
|
|
659
|
+
export const VERIFY_QUEUED_DETAIL = 'Verify runs in Chrome, not here: it re-opens the page, re-resolves the anchor and checks that the console errors the capture recorded are gone; a task it cannot judge goes to needs_review. ' +
|
|
660
|
+
'snag_get the task in ~30 s for the outcome';
|
|
661
|
+
function parseTasks(value) {
|
|
662
|
+
if (!Array.isArray(value))
|
|
663
|
+
return [];
|
|
664
|
+
const tasks = [];
|
|
665
|
+
for (const entry of value) {
|
|
666
|
+
const parsed = TaskSchema.safeParse(entry);
|
|
667
|
+
if (parsed.success)
|
|
668
|
+
tasks.push(parsed.data);
|
|
669
|
+
}
|
|
670
|
+
return tasks;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The identity rule, shared by discovery and by the re-check before a write.
|
|
674
|
+
*
|
|
675
|
+
* `root` absent means a daemon too old to say; that is not evidence of a
|
|
676
|
+
* mismatch. A different root is accepted only when git says both are
|
|
677
|
+
* checkouts of one repository — see "Which snagd" above.
|
|
678
|
+
*/
|
|
679
|
+
export async function daemonIdentity(health, root, lookup = defaultGitCommonDir) {
|
|
680
|
+
if (health.name !== undefined && health.name !== 'snagd')
|
|
681
|
+
return { ok: false, reason: 'not_snagd' };
|
|
682
|
+
if (typeof health.root !== 'string' || samePath(health.root, root))
|
|
683
|
+
return { ok: true };
|
|
684
|
+
if (await sameRepository(health.root, root, lookup))
|
|
685
|
+
return { ok: true };
|
|
686
|
+
return { ok: false, reason: 'other_root', daemonRoot: health.root };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Find the daemon: `.snag/.port` first, then a bounded probe of 7654–7664.
|
|
690
|
+
*
|
|
691
|
+
* `GET /health` is unauthenticated by design (doc 02 §7.1) and reports
|
|
692
|
+
* `name: 'snagd'`, which is what tells us a stray service on 7655 is not the
|
|
693
|
+
* daemon — the appendix is explicit that the probe must be able to make that
|
|
694
|
+
* distinction.
|
|
695
|
+
*
|
|
696
|
+
* A daemon serving exactly this root wins outright. Failing that, the first
|
|
697
|
+
* daemon serving another worktree of the same repository is taken. Failing
|
|
698
|
+
* that, if any snagd answered for a different project this **throws**
|
|
699
|
+
* `DaemonRootMismatchError` naming it, and resolves `null` only when no snagd
|
|
700
|
+
* answered at all.
|
|
701
|
+
*/
|
|
702
|
+
export async function connectToDaemon(options) {
|
|
703
|
+
const token = options.token === undefined ? readToken() : options.token;
|
|
704
|
+
const fetchImpl = options.fetchImpl ?? globalFetch();
|
|
705
|
+
const lookup = options.gitCommonDir ?? defaultGitCommonDir;
|
|
706
|
+
const candidates = [];
|
|
707
|
+
if (options.baseUrl !== undefined) {
|
|
708
|
+
candidates.push(options.baseUrl);
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
const fromFile = readPortFile(options.root);
|
|
712
|
+
if (fromFile !== null)
|
|
713
|
+
candidates.push(`http://${DAEMON_HOST}:${fromFile}`);
|
|
714
|
+
const range = options.portRange ?? { start: DAEMON_PORT_START, end: DAEMON_PORT_END };
|
|
715
|
+
for (let port = range.start; port <= range.end; port += 1) {
|
|
716
|
+
const url = `http://${DAEMON_HOST}:${port}`;
|
|
717
|
+
if (!candidates.includes(url))
|
|
718
|
+
candidates.push(url);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/** snagds serving some other root, in probe order: `git` is asked about them only if needed. */
|
|
722
|
+
const elsewhere = [];
|
|
723
|
+
for (const baseUrl of candidates) {
|
|
724
|
+
const client = new DaemonClient({
|
|
725
|
+
baseUrl,
|
|
726
|
+
token,
|
|
727
|
+
fetchImpl,
|
|
728
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
729
|
+
});
|
|
730
|
+
let health;
|
|
731
|
+
try {
|
|
732
|
+
health = await client.health();
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (health.name !== undefined && health.name !== 'snagd')
|
|
738
|
+
continue;
|
|
739
|
+
// One snagd per project, all sharing 7654–7664 and one token. A daemon
|
|
740
|
+
// serving a different repo answers every route correctly — for the wrong
|
|
741
|
+
// queue — so a claim here would move someone else's task.
|
|
742
|
+
if (typeof health.root === 'string' && !samePath(health.root, options.root)) {
|
|
743
|
+
elsewhere.push({ baseUrl, daemonRoot: health.root });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
return new DaemonClient({ baseUrl, token, fetchImpl });
|
|
747
|
+
}
|
|
748
|
+
// No daemon for this exact root. A worktree's daemon is the next best —
|
|
749
|
+
// the same repository, the same queue — but never ahead of an exact match,
|
|
750
|
+
// or two worktrees each running their own snagd would share one.
|
|
751
|
+
for (const { baseUrl, daemonRoot } of elsewhere) {
|
|
752
|
+
if (await sameRepository(daemonRoot, options.root, lookup)) {
|
|
753
|
+
return new DaemonClient({ baseUrl, token, fetchImpl });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const first = elsewhere[0];
|
|
757
|
+
if (first !== undefined) {
|
|
758
|
+
throw new DaemonRootMismatchError(path.resolve(options.root), first.daemonRoot, first.baseUrl);
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The real backend: daemon when it answers, `.snag/` when it does not.
|
|
764
|
+
*
|
|
765
|
+
* The daemon handle is resolved once, lazily, and re-resolved after a failure
|
|
766
|
+
* so a daemon started mid-session is picked up without restarting the MCP
|
|
767
|
+
* server (Claude Code keeps a stdio server alive for the whole session).
|
|
768
|
+
*/
|
|
769
|
+
/**
|
|
770
|
+
* How long a failed discovery is remembered before probing again.
|
|
771
|
+
*
|
|
772
|
+
* Without this, every tool call in a burst re-probes all eleven ports. The
|
|
773
|
+
* probe is cheap on a healthy machine (a closed loopback port refuses
|
|
774
|
+
* immediately) but not free, and a black-holed port costs the full timeout
|
|
775
|
+
* each time. Two seconds is long enough to cover one agent turn and short
|
|
776
|
+
* enough that a daemon started mid-session is picked up almost at once —
|
|
777
|
+
* Claude Code keeps a stdio server alive for the whole session, so
|
|
778
|
+
* "restart the MCP server" must never be the fix for "I started snagd".
|
|
779
|
+
*/
|
|
780
|
+
export const DAEMON_RETRY_AFTER_MS = 2_000;
|
|
781
|
+
export class SnagClient {
|
|
782
|
+
root;
|
|
783
|
+
files;
|
|
784
|
+
connect;
|
|
785
|
+
daemonPromise = null;
|
|
786
|
+
/** The handle `daemonPromise` last resolved to, so a stale failure can be told apart. */
|
|
787
|
+
connected = null;
|
|
788
|
+
/**
|
|
789
|
+
* `-Infinity`, not `0`: "no failure yet" must never look like "failed at time
|
|
790
|
+
* zero", or the very first call is swallowed by the backoff window.
|
|
791
|
+
*/
|
|
792
|
+
lastFailureAt = Number.NEGATIVE_INFINITY;
|
|
793
|
+
cachedConfig = null;
|
|
794
|
+
now;
|
|
795
|
+
gitCommonDir;
|
|
796
|
+
/**
|
|
797
|
+
* The snagd the last discovery skipped because it serves another project.
|
|
798
|
+
* Kept through the backoff window, so every write in it can say where the
|
|
799
|
+
* daemon actually is instead of claiming there is none.
|
|
800
|
+
*/
|
|
801
|
+
mismatch = null;
|
|
802
|
+
constructor(root, connect, now = Date.now, gitCommonDir = defaultGitCommonDir) {
|
|
803
|
+
this.root = path.resolve(root);
|
|
804
|
+
this.files = new FileStore(this.root);
|
|
805
|
+
this.gitCommonDir = gitCommonDir;
|
|
806
|
+
this.connect = connect ?? (() => connectToDaemon({ root: this.root, gitCommonDir }));
|
|
807
|
+
this.now = now;
|
|
808
|
+
}
|
|
809
|
+
/** `null` when the daemon is unreachable. Never throws. */
|
|
810
|
+
async daemon() {
|
|
811
|
+
if (this.daemonPromise === null) {
|
|
812
|
+
if (this.now() - this.lastFailureAt < DAEMON_RETRY_AFTER_MS)
|
|
813
|
+
return null;
|
|
814
|
+
this.daemonPromise = this.connect().then((client) => {
|
|
815
|
+
this.mismatch = null;
|
|
816
|
+
return client;
|
|
817
|
+
}, (error) => {
|
|
818
|
+
this.mismatch = error instanceof DaemonRootMismatchError ? error : null;
|
|
819
|
+
return null;
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
const client = await this.daemonPromise;
|
|
823
|
+
if (client === null)
|
|
824
|
+
this.forgetDaemon(null);
|
|
825
|
+
else
|
|
826
|
+
this.connected = client;
|
|
827
|
+
return client;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Drop the cached handle after the daemon stopped answering.
|
|
831
|
+
*
|
|
832
|
+
* A handle that resolved once used to be kept for the life of the MCP
|
|
833
|
+
* server, so a snagd that was stopped and restarted — which picks the first
|
|
834
|
+
* free port and may not get the old one back — was never found again: every
|
|
835
|
+
* call went to the dead port and fell back to files. Forgetting it makes the
|
|
836
|
+
* next call re-probe, after the same backoff window a failed probe gets.
|
|
837
|
+
*
|
|
838
|
+
* `stale` is the handle that failed. Claude Code can run tool calls in
|
|
839
|
+
* parallel, so a failure on a handle another call has already replaced must
|
|
840
|
+
* not throw away the replacement.
|
|
841
|
+
*
|
|
842
|
+
* `backoff: false` is for a handle that answered but is no longer ours: the
|
|
843
|
+
* port is alive, so waiting out the window would only delay finding where
|
|
844
|
+
* our snagd went. The next call re-probes at once.
|
|
845
|
+
*/
|
|
846
|
+
forgetDaemon(stale, { backoff = true } = {}) {
|
|
847
|
+
if (stale !== null && this.connected !== stale)
|
|
848
|
+
return;
|
|
849
|
+
this.connected = null;
|
|
850
|
+
this.daemonPromise = null;
|
|
851
|
+
this.lastFailureAt = backoff ? this.now() : Number.NEGATIVE_INFINITY;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* The daemon, or a thrown `DaemonUnavailableError` — the root-mismatch
|
|
855
|
+
* subclass when the only snagd that answered serves another project.
|
|
856
|
+
*/
|
|
857
|
+
async requireDaemon() {
|
|
858
|
+
const client = await this.daemon();
|
|
859
|
+
if (client === null)
|
|
860
|
+
throw this.mismatch ?? new DaemonUnavailableError(this.root);
|
|
861
|
+
return client;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The daemon for a write, with its identity asked again first.
|
|
865
|
+
*
|
|
866
|
+
* The handle is cached for the life of the MCP server, and a snagd that
|
|
867
|
+
* restarted binds the first free port — which another project's snagd may
|
|
868
|
+
* since have taken. That daemon answers `PATCH /tasks/SNAG-3` perfectly well,
|
|
869
|
+
* for its own SNAG-3. Reads tolerate the risk (they are labelled and cheap to
|
|
870
|
+
* repeat); a status change does not, so `/health` is asked before every one,
|
|
871
|
+
* and a handle that now answers for another root is dropped and discovery
|
|
872
|
+
* runs again on the spot.
|
|
873
|
+
*/
|
|
874
|
+
async writableDaemon() {
|
|
875
|
+
const client = await this.requireDaemon();
|
|
876
|
+
let identity;
|
|
877
|
+
try {
|
|
878
|
+
identity = await daemonIdentity(await client.health(), this.root, this.gitCommonDir);
|
|
879
|
+
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
if (error instanceof DaemonUnavailableError)
|
|
882
|
+
throw this.unavailable(client, error);
|
|
883
|
+
if (!(error instanceof DaemonRequestError))
|
|
884
|
+
throw error;
|
|
885
|
+
// `/health` is unauthenticated on every snagd; a non-2xx is not one.
|
|
886
|
+
identity = { ok: false, reason: 'not_snagd' };
|
|
887
|
+
}
|
|
888
|
+
if (identity.ok)
|
|
889
|
+
return client;
|
|
890
|
+
this.forgetDaemon(client, { backoff: false });
|
|
891
|
+
return this.requireDaemon();
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Forget the daemon if `error` says it is gone, and return the error to throw.
|
|
895
|
+
* The request-level error names the base URL where the project root belongs,
|
|
896
|
+
* so it is rebuilt against the root — its message tells the agent where to
|
|
897
|
+
* start snagd.
|
|
898
|
+
*/
|
|
899
|
+
unavailable(client, error) {
|
|
900
|
+
this.forgetDaemon(client);
|
|
901
|
+
return new DaemonUnavailableError(this.root, error.detail);
|
|
902
|
+
}
|
|
903
|
+
config() {
|
|
904
|
+
this.cachedConfig ??= this.files.readConfig();
|
|
905
|
+
return this.cachedConfig;
|
|
906
|
+
}
|
|
907
|
+
async listTasks(query) {
|
|
908
|
+
const client = await this.daemon();
|
|
909
|
+
if (client !== null) {
|
|
910
|
+
try {
|
|
911
|
+
const daemonQuery = {};
|
|
912
|
+
if (query.status !== undefined)
|
|
913
|
+
daemonQuery.status = query.status;
|
|
914
|
+
if (query.wave !== undefined)
|
|
915
|
+
daemonQuery.wave = query.wave;
|
|
916
|
+
if (query.limit !== undefined)
|
|
917
|
+
daemonQuery.limit = query.limit;
|
|
918
|
+
return { tasks: await client.listTasks(daemonQuery), source: 'daemon' };
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
922
|
+
throw error;
|
|
923
|
+
this.forgetDaemon(client);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
let tasks = this.files.tasks();
|
|
927
|
+
if (query.status !== undefined)
|
|
928
|
+
tasks = tasks.filter((t) => t.status === query.status);
|
|
929
|
+
if (query.wave !== undefined)
|
|
930
|
+
tasks = tasks.filter((t) => t.wave === query.wave);
|
|
931
|
+
tasks = [...tasks].sort((a, b) => a.wave - b.wave || a.order - b.order);
|
|
932
|
+
if (query.limit !== undefined)
|
|
933
|
+
tasks = tasks.slice(0, query.limit);
|
|
934
|
+
return { tasks, source: 'files' };
|
|
935
|
+
}
|
|
936
|
+
async getTask(id) {
|
|
937
|
+
const client = await this.daemon();
|
|
938
|
+
if (client !== null) {
|
|
939
|
+
try {
|
|
940
|
+
const found = await client.getTask(id);
|
|
941
|
+
if (found === null)
|
|
942
|
+
return null;
|
|
943
|
+
return {
|
|
944
|
+
task: found.task,
|
|
945
|
+
file: found.file ?? this.files.taskMarkdownPath(found.task),
|
|
946
|
+
source: 'daemon',
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
951
|
+
throw error;
|
|
952
|
+
this.forgetDaemon(client);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const task = this.files.tasks().find((t) => t.uid === id || t.id === id);
|
|
956
|
+
if (task === undefined)
|
|
957
|
+
return null;
|
|
958
|
+
return { task, file: this.files.taskMarkdownPath(task), source: 'files' };
|
|
959
|
+
}
|
|
960
|
+
async commit(body) {
|
|
961
|
+
const client = await this.writableDaemon();
|
|
962
|
+
try {
|
|
963
|
+
return await client.commit(body);
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
if (error instanceof DaemonUnavailableError)
|
|
967
|
+
throw this.unavailable(client, error);
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async setStatus(id, status) {
|
|
972
|
+
const client = await this.writableDaemon();
|
|
973
|
+
try {
|
|
974
|
+
return await client.setStatus(id, status);
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
if (error instanceof DaemonUnavailableError)
|
|
978
|
+
throw this.unavailable(client, error);
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async verifyPreflight(taskId) {
|
|
983
|
+
const client = await this.writableDaemon();
|
|
984
|
+
try {
|
|
985
|
+
return await client.verifyPreflight(taskId);
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
if (error instanceof DaemonUnavailableError)
|
|
989
|
+
throw this.unavailable(client, error);
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async verifyStatus(taskId) {
|
|
994
|
+
const client = await this.daemon();
|
|
995
|
+
if (client === null)
|
|
996
|
+
return null;
|
|
997
|
+
try {
|
|
998
|
+
return await client.verifyStatus(taskId);
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
if (error instanceof DaemonUnavailableError)
|
|
1002
|
+
this.forgetDaemon(client);
|
|
1003
|
+
// A verify summary is garnish on snag_get; its absence must not fail the read.
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async queueVerify(taskId, body) {
|
|
1008
|
+
let client;
|
|
1009
|
+
try {
|
|
1010
|
+
client = await this.writableDaemon();
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
1014
|
+
throw error;
|
|
1015
|
+
return {
|
|
1016
|
+
queued: false,
|
|
1017
|
+
detail: error instanceof DaemonRootMismatchError
|
|
1018
|
+
? `no verify run was queued: ${error.message}`
|
|
1019
|
+
: 'snagd is not running, so no verify run was queued',
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
return await client.queueVerify(taskId, body);
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
1027
|
+
throw error;
|
|
1028
|
+
this.forgetDaemon(client);
|
|
1029
|
+
return { queued: false, detail: 'snagd went away before the verify run was queued' };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async plan() {
|
|
1033
|
+
const client = await this.daemon();
|
|
1034
|
+
if (client !== null) {
|
|
1035
|
+
try {
|
|
1036
|
+
return { plan: await client.plan(), source: 'daemon' };
|
|
1037
|
+
}
|
|
1038
|
+
catch (error) {
|
|
1039
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
1040
|
+
throw error;
|
|
1041
|
+
this.forgetDaemon(client);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return { plan: this.files.readPlan(), source: 'files' };
|
|
1045
|
+
}
|
|
1046
|
+
async map() {
|
|
1047
|
+
const client = await this.daemon();
|
|
1048
|
+
if (client !== null) {
|
|
1049
|
+
try {
|
|
1050
|
+
return { map: await client.map(), source: 'daemon' };
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
if (!(error instanceof DaemonUnavailableError))
|
|
1054
|
+
throw error;
|
|
1055
|
+
this.forgetDaemon(client);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { map: this.files.readMap(), source: 'files' };
|
|
1059
|
+
}
|
|
1060
|
+
async health() {
|
|
1061
|
+
const client = await this.daemon();
|
|
1062
|
+
if (client === null)
|
|
1063
|
+
return null;
|
|
1064
|
+
try {
|
|
1065
|
+
return await client.health();
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
if (error instanceof DaemonUnavailableError)
|
|
1069
|
+
this.forgetDaemon(client);
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
//# sourceMappingURL=client.js.map
|