scanruler-mcp 0.4.0 → 0.4.5

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/src/mcp.js CHANGED
@@ -24,7 +24,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
24
24
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
25
25
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
26
26
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
27
- import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles } from './tools.js'
27
+ import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles, writeNew } from './tools.js'
28
28
  import { jsonBytes, replyMeasure } from './trace.js'
29
29
 
30
30
  /** The app's own commands as of this release, so the tools are there before
@@ -117,6 +117,96 @@ function summary(command) {
117
117
  return /^.*?[.!?](?= |$)/.exec(description)?.[0] ?? description
118
118
  }
119
119
 
120
+ /** The names a call would give new things, at the fields its command says
121
+ * name them — `names: { space, at }` in the page's list, `at` paths into
122
+ * the input ("feature.name", "features[].name", a `[]` stepping into each
123
+ * item of a list) and `space` the things whose names may not repeat, in
124
+ * words. Each `{ space, name }`; none for a command that says nothing. */
125
+ export function namesGiven(command, args) {
126
+ const declared = command?.names
127
+ if (!declared || !Array.isArray(declared.at)) return []
128
+ const space = String(declared.space ?? 'names')
129
+ const found = []
130
+ const walk = (value, steps) => {
131
+ if (value === null || typeof value !== 'object') {
132
+ if (steps.length === 0 && typeof value === 'string' && value.trim()) found.push({ space, name: value })
133
+ return
134
+ }
135
+ if (steps.length === 0) return
136
+ const [step, ...rest] = steps
137
+ const list = step.endsWith('[]')
138
+ const next = value[list ? step.slice(0, -2) : step]
139
+ if (!list) walk(next, rest)
140
+ else if (Array.isArray(next)) for (const item of next) walk(item, rest)
141
+ }
142
+ for (const path of declared.at) walk(args ?? {}, String(path).split('.'))
143
+ return found
144
+ }
145
+
146
+ /** Why a batch's calls may not run together: two of them — or one twice —
147
+ * giving new things the same name in one space, trimmed and in any case,
148
+ * which the page refuses at the second, rolling back every change before
149
+ * it. Null when none do. `commandOf` finds a call's command by its tool. */
150
+ export function nameClash(calls, commandOf) {
151
+ const seen = new Map()
152
+ for (let i = 0; i < calls.length; i++) {
153
+ const tool = String(calls[i]?.tool ?? '')
154
+ for (const { space, name } of namesGiven(commandOf(tool), calls[i]?.arguments)) {
155
+ const key = `${space}\u0000${name.trim().toLowerCase()}`
156
+ const first = seen.get(key)
157
+ if (first) {
158
+ const which = first.index === i ? `calls[${i}] (${tool}) gives the name “${name.trim()}” twice` : `calls[${first.index}] (${first.tool}) and calls[${i}] (${tool}) both give the name “${name.trim()}”${first.name.trim() !== name.trim() ? ` (“${first.name.trim()}”)` : ''}`
159
+ return `${which} — names are unique across ${space}, so the page would refuse the second and the batch roll back what came before it. Nothing was run: give each its own name.`
160
+ }
161
+ seen.set(key, { index: i, tool, name })
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ /** A batch's calls as the client sent them — `{ calls }`, or `{ error }`
168
+ * when they cannot be read. Some clients send a large nested value as a
169
+ * string of its JSON: the list of calls, a call, or a call's arguments
170
+ * given so is read as that JSON. Anything else that is not a list is no
171
+ * calls, as before. */
172
+ export function callsGiven(given) {
173
+ const parse = (text) => {
174
+ if (!text.trim()) return { value: undefined }
175
+ try {
176
+ return { value: JSON.parse(text) }
177
+ } catch (e) {
178
+ return { error: e instanceof Error ? e.message : String(e) }
179
+ }
180
+ }
181
+ const SHAPE = '[{ "tool": "…", "arguments": { … } }]'
182
+ let list = given
183
+ if (typeof given === 'string') {
184
+ const read = parse(given)
185
+ if (read.error) return { error: `calls came as a string that is not JSON (${read.error}) — give calls as a list: ${SHAPE}.` }
186
+ list = Array.isArray(read.value?.calls) ? read.value.calls : read.value
187
+ if (!Array.isArray(list)) return { error: `calls came as a string whose JSON is not a list of calls — give calls as a list: ${SHAPE}.` }
188
+ }
189
+ if (!Array.isArray(list)) return { calls: [] }
190
+ const calls = []
191
+ for (let i = 0; i < list.length; i++) {
192
+ let c = list[i]
193
+ if (typeof c === 'string') {
194
+ const read = parse(c)
195
+ if (read.error || !read.value || typeof read.value !== 'object' || Array.isArray(read.value)) return { error: `calls[${i}] came as a string that is not a call's JSON${read.error ? ` (${read.error})` : ''} — each call is { "tool": "…", "arguments": { … } }. Nothing was run.` }
196
+ c = read.value
197
+ }
198
+ if (c && typeof c === 'object' && typeof c.arguments === 'string') {
199
+ const read = parse(c.arguments)
200
+ const tool = typeof c.tool === 'string' ? ` (${c.tool})` : ''
201
+ if (read.error) return { error: `calls[${i}].arguments${tool} came as a string that is not JSON (${read.error}) — give them as an object, {} for none. Nothing was run.` }
202
+ if (read.value !== undefined && (read.value === null || typeof read.value !== 'object' || Array.isArray(read.value))) return { error: `calls[${i}].arguments${tool} came as a string whose JSON is not an object — give them as an object, {} for none. Nothing was run.` }
203
+ c = { ...c, arguments: read.value ?? {} }
204
+ }
205
+ calls.push(c)
206
+ }
207
+ return { calls }
208
+ }
209
+
120
210
  /** What the server says it is when it runs ScanRuler itself, headless. */
121
211
  const HEADLESS_ABOUT = [
122
212
  'These tools drive ScanRuler headless: the app runs inside this server, with no browser, no scan and no viewport — nobody watches it, and nothing is drawn. scanruler_status says so (backend: "headless").',
@@ -174,6 +264,27 @@ const LIST_WAIT_MS = 3_000
174
264
  * says where, how large and what it holds. */
175
265
  export const ANSWER_CAP = 40_000
176
266
 
267
+ /** Commands whose answer may run past ANSWER_CAP before it is written to a
268
+ * file, and how far. A guide is read whole, before a task's first step,
269
+ * and is worth its room: several asked for together are answered while
270
+ * they fit some 80 k characters, the rest named to read next (the page's
271
+ * guide.read), so a read does not reach this. 90 k stays under Claude Code's
272
+ * default limit on a tool's answer, 25 000 tokens (MAX_MCP_OUTPUT_TOKENS)
273
+ * — some 100 k characters at four a token, less for text with many
274
+ * escapes and marks. */
275
+ export const ANSWER_CAPS = { 'guide.read': 90_000 }
276
+
277
+ /** What a spilled answer of a command says to ask for next time: less of
278
+ * it, by the command's own fields — the command's own word (askForLess,
279
+ * which a page sends with its commands) first, else this, for a page from
280
+ * before it did. */
281
+ const ASK_FOR_LESS = {
282
+ 'guide.read': 'one section at a time (section, by its heading — a ### part of a long one too; guide_read with no topic lists them with what each costs)',
283
+ }
284
+
285
+ /** What a command's spilled answer says to ask for next time. */
286
+ const lessOf = (command) => (typeof command?.askForLess === 'string' && command.askForLess ? command.askForLess : ASK_FOR_LESS[command?.name])
287
+
177
288
  /** How many jobs the status lists besides those running, the newest. */
178
289
  const JOBS_LISTED = 5
179
290
 
@@ -185,6 +296,18 @@ const FILES_LISTED = 300
185
296
 
186
297
  const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
187
298
 
299
+ /** The notes an answer carries for the agent (`notes`), when it is an
300
+ * object that has them. */
301
+ const notesIn = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) && v.notes !== undefined && v.notes !== null ? v.notes : undefined)
302
+
303
+ /** `d` as local time with its offset from UTC, to the second —
304
+ * 2026-10-23T18:04:05+02:00 — for an agent whose host gives it no clock. */
305
+ export function localTime(d = new Date()) {
306
+ const pad = (n) => String(Math.trunc(Math.abs(n))).padStart(2, '0')
307
+ const off = -d.getTimezoneOffset()
308
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${off < 0 ? '-' : '+'}${pad(off / 60)}:${pad(off % 60)}`
309
+ }
310
+
188
311
  const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
189
312
  const failure = (e) => {
190
313
  const code = e?.code ?? 'failed'
@@ -214,7 +337,8 @@ const failure = (e) => {
214
337
  * }} options — `tools` is how the page's commands are listed (TOOL_MODES).
215
338
  * `listWaitMs`: how long a listing waits for a page that connected before
216
339
  * and is not back yet. `answerCap`: the most characters an answer
217
- * carries; a longer one goes to a file in `outDir` (ANSWER_CAP).
340
+ * carries; a longer one goes to a file in the folder the scan open now
341
+ * came from, else in `outDir` (ANSWER_CAP).
218
342
  * `trace`, when given, gets a line per call (trace.js).
219
343
  * `answerWithinMs` is how long a job's call is held before it answers
220
344
  * with the job, and the most scanruler_wait waits; 0 holds it to the end.
@@ -290,7 +414,7 @@ export function createMcpServer({
290
414
  // line, and one answered before it was done (queued) gets a line when it
291
415
  // is. `run` is handed the job's name, for the page.
292
416
  const startJob = (tool, run, ctx = null, queued = false) => {
293
- const job = { job: `j${nextJob++}-${copyMark}`, tool, state: 'running', started: Date.now(), ms: null, reply: null, queued }
417
+ const job = { job: `j${nextJob++}-${copyMark}`, tool, state: 'running', started: Date.now(), ms: null, reply: null, queued, progress: null }
294
418
  if (ctx) Object.assign(ctx.fields, { job: job.job, ...(queued ? { queued: true } : {}) })
295
419
  const finished = (reply) => {
296
420
  job.state = reply.isError ? 'failed' : 'done'
@@ -312,7 +436,25 @@ export function createMcpServer({
312
436
  }
313
437
  return job
314
438
  }
315
- const jobBrief = (j) => ({ job: j.job, tool: j.tool, state: j.state, ...(j.ms !== null ? { ms: j.ms } : { runningMs: Date.now() - j.started }) })
439
+ const jobBrief = (j) => ({
440
+ job: j.job,
441
+ tool: j.tool,
442
+ state: j.state,
443
+ ...(j.ms !== null ? { ms: j.ms } : { runningMs: Date.now() - j.started, ...(j.progress ? { progress: j.progress } : {}) }),
444
+ })
445
+ /** A job's last progress line from the page kept — what its status line
446
+ * says, "Measuring deviation — 42%…" — as `{ phase, percent }`, percent
447
+ * null when the line gives none; `call`, in a batch, the index of the
448
+ * call it came from. The status and scanruler_wait answer it while the
449
+ * job runs, whether or not the client asked for progress. */
450
+ const noteProgress = (job, line, call) => {
451
+ if (!job) return
452
+ const text = String(line ?? '').trim()
453
+ if (!text) return
454
+ const m = /(\d+(?:\.\d+)?)\s*%/.exec(text)
455
+ const phase = m ? text.replace(/\s*[—–-]?\s*\d+(?:\.\d+)?\s*%\s*/, ' ').replace(/\s+(?=[…:.,]|$)/g, '').trim() : text
456
+ job.progress = { phase: phase || text, percent: m ? Number(m[1]) : null, ...(call !== undefined ? { call } : {}) }
457
+ }
316
458
  /** A job's answer as scanruler_wait gives it: the tool's own answer
317
459
  * under result, or its refusal under error. */
318
460
  const jobAnswer = (j) => {
@@ -356,9 +498,14 @@ export function createMcpServer({
356
498
  /** Whether the page is done with what it is busy with — polled until
357
499
  * `until` (ms since the epoch). A page that does not say is taken as
358
500
  * idle. */
501
+ /** What the page was busy with when pageIdle last looked — its busy
502
+ * sentence (a build's step in play and how long it has run, say), and
503
+ * the change running and for how long — or null. */
504
+ let pageBusy = null
359
505
  const pageIdle = async (until) => {
360
506
  for (;;) {
361
507
  const state = await link.run('session.state', {}).catch(() => null)
508
+ pageBusy = state && typeof state === 'object' ? { busy: state.busy ?? null, running: state.running ?? null, runningMs: state.runningMs ?? null } : null
362
509
  if (!state || typeof state !== 'object' || (state.busy == null && state.running == null)) return true
363
510
  if (Date.now() >= until) return false
364
511
  await sleep(Math.min(500, until - Date.now()))
@@ -423,20 +570,106 @@ export function createMcpServer({
423
570
  const startedAt = new Date().toISOString()
424
571
  const noJobs = `None asked of this server copy, which started at ${startedAt}. A change asked of an earlier copy is not known here: session_state says what the page holds.`
425
572
 
426
- /** What the page's session holds, in brief, for the status: the scan,
427
- * the project's name, whether it has unsaved changes, and its work
428
- * counted — read off session.state, and left out when the page does not
429
- * answer soon. */
430
- const sessionBrief = async () => {
431
- if (!link.connected) return {}
573
+ /** The page's session.state, or null when there is no page or it does
574
+ * not answer soon. */
575
+ const sessionRead = async () => {
576
+ if (!link.connected) return null
432
577
  let timer
433
578
  const state = await Promise.race([
434
579
  link.run('session.state', {}).catch(() => null),
435
580
  new Promise((r) => (timer = setTimeout(() => r(null), SESSION_WITHIN_MS))),
436
581
  ])
437
582
  clearTimeout(timer)
438
- if (!state || typeof state !== 'object') return {}
583
+ return state && typeof state === 'object' ? state : null
584
+ }
585
+ /** Whether a session holds work: a scan, or anything counted. */
586
+ const holdsWork = (state) => Boolean(state?.scan) || Object.values(state?.project?.holds ?? {}).some((n) => typeof n === 'number' && n > 0)
587
+
588
+ // ---- the scan open now ----------------------------------------------------
589
+ // The folder the scan open in the page came from, when this server opened
590
+ // it — or the project holding it — from a file: the part's own folder,
591
+ // where an answer too long to carry and a batch's picture are written.
592
+ // Not the latest of the folders: a scan_open that saves the session it
593
+ // replaces (saveTo) puts the previous part's folder there, and a scan
594
+ // opened by hand puts nothing. Forgotten when a session is replaced
595
+ // without a file or the page reloads; and the page holding a scan of
596
+ // another name now (opened by hand) holds another part.
597
+ let openScan = null
598
+ /** What a session-replacing call leaves open: its file's folder and the
599
+ * scan's name as the page answered it, or nothing known. */
600
+ const openedFrom = (file, result) => {
601
+ openScan = file ? { folder: dirname(file), scan: typeof result?.scan?.fileName === 'string' ? result.scan.fileName : null } : null
602
+ }
603
+ /** The folder of the scan open now, or null when it was not opened from
604
+ * one here. A page that does not answer soon is taken to hold it still. */
605
+ const scanFolderNow = async () => {
606
+ if (!openScan) return null
607
+ const kept = openScan
608
+ const state = await sessionRead()
609
+ if (state && kept.scan !== null && (state.scan?.fileName ?? null) !== kept.scan) return null
610
+ return kept.folder
611
+ }
612
+
613
+ // ---- the page that came back ----------------------------------------------
614
+ // A page names itself — pageId, new each time it loads — and says when it
615
+ // loaded (since). Another name where the last page held work is a reload
616
+ // (Vite's after an edit, the person's, a tab the browser unloaded), and
617
+ // what that page held is gone: the next answer says so, with the project
618
+ // last opened or saved here, before a no_scan is read as a session never
619
+ // had. A page from before pageId is not told apart. `stale`: what the
620
+ // page said when it could not load a part of the app (stale_page).
621
+ let pageSeen = null
622
+ let reloaded = null
623
+ const seePage = (connected) => {
624
+ if (!link.page) return
625
+ const id = typeof link.page.pageId === 'string' && link.page.pageId ? link.page.pageId : null
626
+ if (pageSeen && (id === null ? !connected : pageSeen.id === id)) return
627
+ if (id !== null && pageSeen?.id && pageSeen.held) {
628
+ const r = { since: typeof link.page.since === 'string' ? link.page.since : null, empty: null }
629
+ reloaded = r
630
+ // Whether the new page holds anything, read once while it is new.
631
+ sessionRead().then((state) => {
632
+ if (state) r.empty = !holdsWork(state)
633
+ })
634
+ }
635
+ // Another page holds nothing this server opened.
636
+ if (pageSeen) openScan = null
637
+ pageSeen = { id, held: false, stale: null }
638
+ }
639
+ /** The note a reload is told by, once. */
640
+ const reloadNote = ({ since, empty }) => {
641
+ const at = since && Number.isFinite(Date.parse(since)) ? ` at ${localTime(new Date(since)).slice(11, 16)}` : ''
642
+ const now = empty === true ? ' and holds nothing now' : empty === false ? ' — a new page, holding work of its own' : ''
643
+ const project = knownProject()
644
+ return (
645
+ `The ScanRuler page reloaded${at}${now}: what the page held before is gone, unless it was saved. ` +
646
+ (project ? `The last project opened or saved here was ${project} (project_load opens it).` : 'No project was opened or saved through this server.')
647
+ )
648
+ }
649
+ /** A reply with the reload said in it, when one is to be said: in an
650
+ * object answer as pageReloaded, first; after a refusal's message. */
651
+ const withReload = (reply) => {
652
+ if (!reloaded || !link.connected) return reply
653
+ const block = reply?.content?.find((x) => x.type === 'text')
654
+ if (!block) return reply
655
+ const note = reloadNote(reloaded)
656
+ reloaded = null
657
+ const body = block.text.trimStart()
658
+ const said = reply.isError || !body.startsWith('{') ? `${block.text} — ${note}` : `{"pageReloaded":${JSON.stringify(note)}${/^\{\s*\}$/.test(body) ? '}' : `,${body.slice(1)}`}`
659
+ // A copy: a job keeps its reply as it was, for scanruler_wait.
660
+ return { ...reply, content: reply.content.map((x) => (x === block ? { ...x, text: said } : x)) }
661
+ }
662
+
663
+ /** What the page's session holds, in brief, for the status: the scan,
664
+ * the project's name, whether it has unsaved changes, and its work
665
+ * counted — read off session.state, and left out when the page does not
666
+ * answer soon. */
667
+ const sessionBrief = async () => {
668
+ const state = await sessionRead()
669
+ if (!state) return {}
439
670
  const project = state.project ?? {}
671
+ if (pageSeen && holdsWork(state)) pageSeen.held = true
672
+ if (pageSeen && typeof state.stale?.note === 'string') pageSeen.stale = state.stale.note
440
673
  return {
441
674
  session: {
442
675
  scan: state.scan?.fileName ?? null,
@@ -444,41 +677,72 @@ export function createMcpServer({
444
677
  unsaved: project.unsaved ?? null,
445
678
  ...(project.holds ?? {}),
446
679
  },
447
- // The page's word that this server is older than its commands assume.
448
- ...(typeof state.serverTooOld === 'string' ? { serverTooOld: state.serverTooOld } : {}),
680
+ // The page's word that the server it is connected to is older than its
681
+ // commands assume — while relaying, that is the holding copy.
682
+ ...(typeof state.serverTooOld === 'string' ? { serverTooOld: tooOldHere(state.serverTooOld) } : {}),
449
683
  }
450
684
  }
451
685
 
686
+ /** The page judges the copy it is connected to. A relaying copy is not
687
+ * that one: say which is out of date, and that quitting its app is the
688
+ * fix — updating this one changes nothing. */
689
+ const tooOldHere = (said) => {
690
+ const info = serverInfo()
691
+ if (info.role !== 'relay') return said
692
+ const holder = info.holder?.version ? `version ${info.holder.version}` : 'from before 0.4.1'
693
+ return (
694
+ `This server (${info.version}) is current; it sends its calls through another copy of scanruler-mcp, ${holder}, which holds the tab’s connection and is the one out of date. ` +
695
+ 'Another AI app or chat started it — quit that app completely (Claude Desktop, Claude Code, Codex or another), and this copy takes the connection over within about ten seconds; or update that app’s setting as the page says: ' +
696
+ said
697
+ )
698
+ }
699
+
452
700
  // ---- answers that fit -----------------------------------------------------
453
701
  // An answer past answerCap — a read with every point in it, a batch of
454
702
  // many — would crowd out the agent's context: it is written whole to a
455
- // file in outDir, and the answer says where, how large and what it holds.
456
- /** `value` as an answer gives it: itself, or — its JSON past the cap —
457
- * `{ truncated, file, bytes, keys, brief }`, brief its short fields and
458
- * those named in `keep`. `tool` names the file. A file that cannot be
459
- * written leaves the answer whole. */
460
- const fitted = async (value, tool, exact = false, keep = []) => {
703
+ // file, and the answer says where, how large and what it holds. The file
704
+ // goes into the folder the scan open now came from (scanFolderNow) — a
705
+ // sandboxed agent can open that one, not the server's outDir — else, or
706
+ // when that folder takes no file, into outDir.
707
+ /** The most characters a command's answer carries (ANSWER_CAPS), never
708
+ * under answerCap. */
709
+ const capOf = (command) => Math.max(answerCap, ANSWER_CAPS[command?.name] ?? 0)
710
+ /** `value` as an answer gives it: itself, or — its JSON past `cap` —
711
+ * `{ truncated, file, bytes, keys, brief }`, brief its short fields,
712
+ * those named in `keep` and its notes. `tool` names the file, numbered
713
+ * past one there (writeNew), so answers spilled at once never share it;
714
+ * `less` says what to ask for next time. A file that cannot be written
715
+ * leaves the answer whole. */
716
+ const fitted = async (value, tool, exact = false, keep = [], { cap = answerCap, less = 'fields, limit' } = {}) => {
461
717
  const body = agentJson(value, exact)
462
- if (body.length <= answerCap) return value
718
+ if (body.length <= cap) return value
463
719
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
464
- let file = join(outDir, `${tool}-${stamp}.json`)
720
+ const name = `${tool}-${stamp}.json`
721
+ const scanFolder = await scanFolderNow()
722
+ let file
723
+ let where
465
724
  try {
466
- for (let n = 2; await stat(file).catch(() => null); n++) file = join(outDir, `${tool}-${stamp}-${n}.json`)
467
- await mkdir(outDir, { recursive: true })
468
- await writeFile(file, body, 'utf8')
725
+ file = scanFolder ? await writeNew(scanFolder, name, body).catch(() => null) : null
726
+ where = file ? `It is in ${scanFolder}, the folder the scan open now was opened from.` : `It is in ${outDir}, the server's output folder${scanFolder ? ` — ${scanFolder}, the scan's folder, took no file` : ': the scan open now was not opened from a folder through this server'}.`
727
+ file ??= await writeNew(outDir, name, body)
469
728
  } catch {
470
729
  return value
471
730
  }
472
731
  const object = value !== null && typeof value === 'object' && !Array.isArray(value)
473
- const short = (k, v) => keep.includes(k) || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
732
+ const short = (k, v) => keep.includes(k) || k === 'notes' || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
474
733
  const brief = object ? Object.fromEntries(Object.entries(value).filter(([k, v]) => short(k, v))) : {}
734
+ // The notes the person left for the agent ride in every brief, where
735
+ // the answer gave them or one level down (a job's result, a
736
+ // workspace's part of the session): a note read late is a step done
737
+ // over.
738
+ if (object) for (const [k, v] of Object.entries(value)) if (!(k in brief) && notesIn(v) !== undefined) brief[k] = { notes: notesIn(v) }
475
739
  return {
476
740
  truncated: true,
477
741
  file,
478
742
  bytes: Buffer.byteLength(body, 'utf8'),
479
743
  ...(object ? { keys: Object.keys(value) } : Array.isArray(value) ? { items: value.length } : {}),
480
744
  ...(Object.keys(brief).length ? { brief } : {}),
481
- note: `The answer is ${Math.round(body.length / 1000)} k characters, past the ${Math.round(answerCap / 1000)} k an answer carries, so it was written whole to file as JSON: read what you need from there, or ask for less next time (fields, limit).`,
745
+ note: `The answer is ${Math.round(body.length / 1000)} k characters, past the ${Math.round(cap / 1000)} k an answer carries, so it was written whole to file as JSON: read what you need from there, or ask for less next time (${less}). ${where}`,
482
746
  }
483
747
  }
484
748
 
@@ -506,6 +770,35 @@ export function createMcpServer({
506
770
  // Not kept: this copy still has it.
507
771
  }
508
772
  }
773
+ /** The folder every relative path is taken from — a batch's file, a
774
+ * $file, a file read, a file written: the latest of those folders, as
775
+ * scanruler_write and scanruler_files take it, else the folder this
776
+ * server started in. A client may start the server anywhere (Claude
777
+ * Desktop in its own install folder); the scan's folder is where the
778
+ * agent's files are. */
779
+ const pathBase = () => knownFolders()[0] ?? cwd
780
+
781
+ // The project file last opened or saved here, kept with the folders: what
782
+ // a page that came back empty is pointed to.
783
+ let lastProject = null
784
+ const knownProject = () => {
785
+ try {
786
+ const kept = folderStore?.loadProject?.()
787
+ if (kept) lastProject = kept
788
+ } catch {
789
+ // Not read: this copy's own stands.
790
+ }
791
+ return lastProject
792
+ }
793
+ const rememberProject = (file) => {
794
+ if (extname(String(file)).toLowerCase() !== '.scanruler') return
795
+ lastProject = file
796
+ try {
797
+ folderStore?.saveProject?.(file)
798
+ } catch {
799
+ // Not kept: this copy still has it.
800
+ }
801
+ }
509
802
 
510
803
  /** The jobs as the status lists them, newest first: every one running
511
804
  * and the newest JOBS_LISTED others; jobsMore counts the rest kept. */
@@ -529,13 +822,16 @@ export function createMcpServer({
529
822
  viewport: link.page.viewport ?? null,
530
823
  hidden: link.page.hidden ?? null,
531
824
  commands: link.page.commands.length,
825
+ ...(typeof link.page.since === 'string' ? { since: link.page.since } : {}),
826
+ ...(pageSeen?.stale ? { stale: true, staleNote: pageSeen.stale } : {}),
532
827
  }
533
828
  : null,
534
- server: { ...serverInfo(), answerWithin: holdS },
829
+ server: { ...serverInfo(), now: localTime(), cwd, relativeTo: pathBase(), answerWithin: holdS },
535
830
  ...(link.connected || headless ? {} : { howToConnect: connectHelp() }),
536
831
  ...(link.page?.hidden ? { hiddenNote: 'The ScanRuler tab is hidden — in the background, its window covered or minimised, or the screen locked. Changes still run; pictures and anything that waits on the screen are slower, and a browser that unloads background tabs (Chrome’s Memory Saver) may close it: add the site to "Always keep these sites active".' } : {}),
537
832
  ...(jobs.size > 0 ? jobsListed() : { jobsNote: noJobs }),
538
833
  ...(!headless && knownFolders().length ? { folders: knownFolders() } : {}),
834
+ ...(!headless && knownProject() ? { lastProject: knownProject() } : {}),
539
835
  ...staleTools(),
540
836
  })
541
837
 
@@ -543,10 +839,14 @@ export function createMcpServer({
543
839
  {
544
840
  name: 'scanruler_status',
545
841
  title: 'ScanRuler connection status',
546
- description: 'Whether a ScanRuler page is connected to this agent, which version and plugins it has, whether its tab is hidden (page.hidden), and — when none is — how the user connects one. session, with a page, says what it holds: the scan, the project’s name, whether it has changes not saved (unsaved), and its work counted — elements, features, notes … — so a session someone else left is seen before it is replaced (scan_open with saveTo keeps it). backend says whether ScanRuler runs in a tab (page) or in this server (headless). jobs lists the calls that change the session still running and the latest five others, newest first, each running, done or failed — a call whose answer you did not get is among them; scanruler_wait { for: "job", job } gives its answer, also for a job of an earlier copy of this server, which the page remembers; jobsMore counts the older ones kept. folders: where a scan or a project was opened from or saved into, the latest first — what scanruler_write writes into and scanruler_files lists; kept when the server restarts. server.answerWithin: the seconds a call is held at most before it answers with its job. server.version is this server’s, server.latest the newest on npm when known, server.handedOffFrom the older copy that started this one in its place. serverTooOld, when there, says this server is older than the page needs: tell the person once, in its words, and install nothing yourself. tools, when there, says the tools you were given are not the ones the page offers now (stale): which were added, removed or changed — named the first time, counted after — and which page they were listed from.',
842
+ description: 'Whether a ScanRuler page is connected to this agent, which version and plugins it has, whether its tab is hidden (page.hidden), and — when none is — how the user connects one. session, with a page, says what it holds: the scan, the project’s name, whether it has changes not saved (unsaved), and its work counted — elements, features, notes … — so a session someone else left is seen before it is replaced (scan_open with saveTo keeps it). page.since: when the page loaded. page.stale, when there: the page could not load a part of the app (an answer said stale_page) — it is older than the app it came from, rebuilt since: save the project, then ask the person to reload, as page.staleNote says. lastProject: the project file last opened or saved through this server — what to open again when the page reloaded (an answer then says pageReloaded). backend says whether ScanRuler runs in a tab (page) or in this server (headless). jobs lists the calls that change the session still running and the latest five others, newest first, each running, done or failed, a running one with progress: { phase, percent } when the page has said how far it is — a call whose answer you did not get is among them; scanruler_wait { for: "job", job } gives its answer, also for a job of an earlier copy of this server, which the page remembers; jobsMore counts the older ones kept. folders: where a scan or a project was opened from or saved into, the latest first — what scanruler_write writes into and scanruler_files lists; kept when the server restarts. server.answerWithin: the seconds a call is held at most before it answers with its job. server.now: the time on this computer, local, with its offset from UTC (2026-10-23T18:04:05+02:00) — the clock to date a report or time a step by. server.cwd: the folder this server started in; server.relativeTo: the folder every relative path is taken from — the latest of folders, else cwd; server.outDir: where a file goes that no path was given for — but an answer too long to carry (truncated: true, file), and a picture in a batch, go into the folder the scan open now was opened from, when it was opened here by a path. server.version is this server’s, server.latest the newest on npm when known, server.handedOffFrom the older copy that started this one in its place. server.role: holder — this copy holds the port and the tab’s connection; relay — another copy (another chat or AI app) holds them and this one sends its calls through it, the work still running in the tab, server.holder.version saying which (null: from before 0.4.1); none — neither yet, server.error saying why. serverTooOld, when there, says the server the page is connected to is older than the page needs — while relaying, the holding copy, not this one: tell the person once, in its words, and install nothing yourself. tools, when there, says the tools you were given are not the ones the page offers now (stale): which were added, removed or changed — named the first time, counted after — and which page they were listed from.',
547
843
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
548
844
  annotations: { readOnlyHint: true, openWorldHint: false },
549
- call: async () => text({ ...status(), ...(await sessionBrief()) }),
845
+ call: async () => {
846
+ // The session read first: it may find the page stale.
847
+ const brief = await sessionBrief()
848
+ return text({ ...status(), ...brief })
849
+ },
550
850
  },
551
851
  {
552
852
  name: 'scanruler_open',
@@ -567,7 +867,7 @@ export function createMcpServer({
567
867
  {
568
868
  name: 'scanruler_wait',
569
869
  title: 'Wait for ScanRuler',
570
- description: `Wait until a ScanRuler page is connected (for: "page", the default), or until it is also done with what it is busy with and every change still running has finished (for: "idle" — then finished lists the changes done or failed since the last such answer, a failed one with its error, and state says idle or running), or until a job is done (for: "job" with job — a call started with wait: false, one answered queued, or one whose answer did not come; scanruler_status lists them). Returns the status — for idle only connected, state and finished — or for a job its answer: { job, tool, state, ms, result } or { …, error: { code, message } } — state running if the time ran out first: call it again. A job of an earlier copy of this server is read from the page's record of it (session.recent), from says so.${holdS ? ` It waits ${holdS} s at most, this server's answer limit, whatever timeoutSeconds says.` : ''} connected is false if the time ran out waiting for a page.`,
870
+ description: `Wait until a ScanRuler page is connected (for: "page", the default), or until it is also done with what it is busy with and every change still running has finished (for: "idle" — then finished lists the changes done or failed since the last such answer, a failed one with its error, and state says idle or running; running, with busy — what the page is at, in its words: a model's build names the step in play and how long it has run — and running, the change still running, with runningS), or until a job is done (for: "job" with job — a call started with wait: false, one answered queued, or one whose answer did not come; scanruler_status lists them). Returns the status — for idle only connected, state and finished, and busy and running while running — or for a job its answer: { job, tool, state, ms, result } or { …, error: { code, message } } — state running if the time ran out first, with runningMs and, once the page has said how far it is, progress: { phase, percent } (its status line: "Measuring deviation", 42): call it again. A job of an earlier copy of this server is read from the page's record of it (session.recent), from says so.${holdS ? ` It waits ${holdS} s at most, this server's answer limit, whatever timeoutSeconds says.` : ''} connected is false if the time ran out waiting for a page.`,
571
871
  inputSchema: {
572
872
  type: 'object',
573
873
  properties: {
@@ -612,7 +912,10 @@ export function createMcpServer({
612
912
  for (const j of finished) j.told = true
613
913
  // The state and what finished, not the whole status: an agent
614
914
  // waits for idle many times in a run.
615
- return text({ connected: link.connected, state: idle ? 'idle' : 'running', finished: finished.map(finishedOut), ...(hiddenNow() ? { hidden: true } : {}), ...staleTools() })
915
+ // Not idle: what the page is at, as it says it — a build's step
916
+ // in play and how long it has run.
917
+ const at = idle || !pageBusy ? {} : { ...(pageBusy.busy ? { busy: pageBusy.busy } : {}), ...(pageBusy.running ? { running: pageBusy.running, ...(pageBusy.runningMs != null ? { runningS: Math.round(pageBusy.runningMs / 1000) } : {}) } : {}) }
918
+ return text({ connected: link.connected, state: idle ? 'idle' : 'running', ...at, finished: finished.map(finishedOut), ...(hiddenNow() ? { hidden: true } : {}), ...staleTools() })
616
919
  }
617
920
  return text(status())
618
921
  },
@@ -738,7 +1041,7 @@ export function createMcpServer({
738
1041
  // a picture — a batch gives a picture as its text and a file, so those are called
739
1042
  // directly; scanruler_tools reads commands() when it is called.
740
1043
  const shown = (c) => !compact || Boolean(c.returnsImage)
741
- const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir }))]
1044
+ const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir, cwd }))]
742
1045
  const listSource = () =>
743
1046
  link.page?.commands?.length ? `the page, ScanRuler ${link.page.version ?? 'of no version'}` : lastPage ? lastPage.from : `this server’s own list (scanruler-mcp ${version})`
744
1047
 
@@ -802,14 +1105,18 @@ export function createMcpServer({
802
1105
  }
803
1106
  for (const event of ['connected', 'commands']) {
804
1107
  link.on(event, () => {
1108
+ seePage(event === 'connected')
805
1109
  remember()
806
1110
  relist()
807
1111
  })
808
1112
  }
1113
+ seePage(true)
809
1114
 
810
1115
  /** One of the page's tools run on the page: the result as MCP content,
811
1116
  * or a failure. `batched`: a picture is written to a file — the call's
812
- * path or a new one in outDir — and given as its text, not shown. `job`:
1117
+ * path, or a new one in the scan's folder (scanFolderNow), else in
1118
+ * outDir — and
1119
+ * given as its text, not shown. `job`:
813
1120
  * the job the call is part of, which the page keeps its outcome under.
814
1121
  * `deferred`: more changes follow straight after it (a batch with
815
1122
  * build: "end"), and the page may leave their rebuild to the last.
@@ -823,18 +1130,28 @@ export function createMcpServer({
823
1130
  return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
824
1131
  }
825
1132
  try {
826
- const args = await withFiles(given ?? {}, cwd)
827
- const { input, binaryKey, file } = await inputFor(command, args, { cwd })
1133
+ // Every relative path in the call from one folder, taken before a
1134
+ // scan opened by it moves it.
1135
+ const base = pathBase()
1136
+ const args = await withFiles(given ?? {}, base)
1137
+ const { input, binaryKey, file } = await inputFor(command, args, { cwd: base })
828
1138
  // A session kept before it is replaced: saved, then let go.
829
- const savedTo = replacesSession(command) && args?.saveTo !== undefined ? await saveSession(String(args.saveTo), job) : undefined
1139
+ const savedTo = replacesSession(command) && args?.saveTo !== undefined ? await saveSession(String(args.saveTo), job, base) : undefined
830
1140
  if (savedTo !== undefined) input.discard = true
831
1141
  const started = Date.now()
832
1142
  let result = await link.run(command.name, input, binaryKey, onProgress, job, deferred)
833
1143
  // The folder a scan or a project was opened from, for scanruler_write.
834
- if (binaryKey && replacesSession(command) && file) rememberFolder(file)
1144
+ if (binaryKey && replacesSession(command) && file) {
1145
+ rememberFolder(file)
1146
+ rememberProject(file)
1147
+ }
1148
+ // And the scan open now: this one's, or — replaced without a file —
1149
+ // none known.
1150
+ if (replacesSession(command)) openedFrom(binaryKey ? file : null, result)
1151
+ if (!command.readOnly && pageSeen) pageSeen.held = true
835
1152
  if (savedTo !== undefined && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, savedTo }
836
1153
  if (ms && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, ms: Date.now() - started }
837
- const image = command.returnsImage && !batched ? await imageContent(result, args, { cwd, outDir }) : null
1154
+ const image = command.returnsImage && !batched ? await imageContent(result, args, { cwd: base, outDir }) : null
838
1155
  if (image) return { content: image }
839
1156
  // Which steps a change's build played, and how long each took, only
840
1157
  // for a batch asked for its timings.
@@ -842,9 +1159,13 @@ export function createMcpServer({
842
1159
  const { played: _played, ...steps } = result.steps
843
1160
  result = { ...result, steps }
844
1161
  }
845
- const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd, outDir }) : await saveFileResult(result, args, { cwd, outDir })
846
- return text(await fitted(saved, name, Boolean(command.exact)), Boolean(command.exact))
1162
+ const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd: base, outDir, folder: args?.path ? null : await scanFolderNow() }) : await saveFileResult(result, args, { cwd: base, outDir })
1163
+ // A project saved to a file: the one a reloaded page is pointed to.
1164
+ if (saved?.file?.path) rememberProject(saved.file.path)
1165
+ return text(await fitted(saved, name, Boolean(command.exact), [], { cap: capOf(command), ...(lessOf(command) ? { less: lessOf(command) } : {}) }), Boolean(command.exact))
847
1166
  } catch (e) {
1167
+ // A page older than its app, said once so: the status keeps it.
1168
+ if (e?.code === 'stale_page' && pageSeen) pageSeen.stale ??= typeof e.message === 'string' ? e.message : 'stale_page'
848
1169
  return failure(e)
849
1170
  }
850
1171
  }
@@ -852,7 +1173,7 @@ export function createMcpServer({
852
1173
  /** The session saved as a project file at `path` (a file, or a folder
853
1174
  * for its own name): where it went, or null when the session held
854
1175
  * nothing to save. Throws, saving nothing, when the save fails. */
855
- const saveSession = async (path, job) => {
1176
+ const saveSession = async (path, job, base = pathBase()) => {
856
1177
  let result
857
1178
  try {
858
1179
  result = await link.run('project.save', {}, undefined, undefined, job)
@@ -860,22 +1181,24 @@ export function createMcpServer({
860
1181
  if (/nothing to save/i.test(e?.message ?? '')) return null
861
1182
  throw { code: e?.code ?? 'failed', message: `Nothing was replaced: the session could not be saved first — ${e?.message ?? String(e)}` }
862
1183
  }
863
- const written = await saveFileResult(result, { path }, { cwd, outDir })
1184
+ const written = await saveFileResult(result, { path }, { cwd: base, outDir })
864
1185
  if (!written?.file?.path) throw { code: 'failed', message: 'Nothing was replaced: project.save gave no file to save.' }
865
1186
  rememberFolder(written.file.path)
1187
+ rememberProject(written.file.path)
866
1188
  return written.file.path
867
1189
  }
868
1190
 
869
1191
  // Several of the page's tools in one call — what cuts a run of twenty
870
1192
  // scan_nearest calls to one round trip. Each answer is the tool's own; a
871
1193
  // picture comes back as the text beside it, not as an image, and is
872
- // written to a file — the call's path, or a new one in outDir — which the
1194
+ // written to a file — the call's path, or a new one in the folder the
1195
+ // scan open now came from (scanFolderNow), else in outDir — which the
873
1196
  // text names.
874
1197
  own.push({
875
1198
  name: 'scanruler_batch',
876
1199
  title: 'Run several ScanRuler tools',
877
1200
  description:
878
- `Run several of the ScanRuler tools in one call, in order, each with its own arguments — a run of scan_nearest, element_fit or section_get calls, say. Returns results, one per call in order: { tool, ok, result } or { tool, ok: false, error: { code, message } }. The first refused stops the rest (stopped says which index, failed: { index, tool, error } which call) unless stopOnError is false. When the refused call is a change, then with rollback (the default) every change the batch made before it is undone, so the batch stands or falls whole: undone names the steps taken back, newest first. A refused read — section_get, scan_query, any tool that changes nothing — undoes nothing: what came before it stands (kept says so). A call refused busy is run again once the page is idle (retried: "busy" on its result). Each call that changes the session is still its own undo step. build: "end" builds a modelled part once for a run of changes to it — sketches edited one after another, say — rather than after each: every call but the last is sent deferred, and a change so sent answers before its rebuild (build: "held", its result and bodies the part as last built; a pick by index names a face of that part); the last call builds them all, and so does a read of the part, or any other kind of tool, met on the way. timings: true gives each call its ms and the batch total_ms, and a change\'s build the steps it played with their ms (result.steps.played). wait: false answers at once with { queued, job }, and so does a batch still running at the server’s answer limit (with after, its seconds); scanruler_wait { for: "job", job } gives the batch\'s answer. A { "$file": path } anywhere in a call’s arguments is that JSON file’s contents. A tool that returns a picture is not shown here: the picture is written to a file — its call’s path, or a new file in ${outDir} — and its result gives that file.path; call the tool directly to see the picture. The server’s own tools (scanruler_status, _open, _wait, _batch, _write, _files, _tools) cannot be batched.`,
1201
+ `Run several of the ScanRuler tools in one call, in order, each with its own arguments — a run of scan_nearest, element_fit or section_get calls, say. Returns results, one per call in order: { tool, ok, result } or { tool, ok: false, error: { code, message } }. The first refused stops the rest (stopped says which index, failed: { index, tool, error } which call) unless stopOnError is false. notes, when a call answered with the notes left for you, is the last call’s, repeated at the top. When the refused call is a change, then with rollback (the default) every change the batch made before it is undone, so the batch stands or falls whole: undone names the steps taken back, newest first, callsUndone counts the calls, and each of those answers ok: false and undone: true, its result kept as it was. Before anything runs, the names the calls would give new things (a command that says which of its fields name them, in one name space) are checked against each other: two alike — case and spaces at the ends aside — refuse the batch with nothing run, naming both calls; a name the session already has is the page’s to refuse. A refused read — section_get, scan_query, any tool that changes nothing — undoes nothing: what came before it stands (kept says so). A call refused busy is run again once the page is idle (retried: "busy" on its result). Each call that changes the session is still its own undo step. build: "end" builds a modelled part once for a run of changes to it — sketches edited one after another, say — rather than after each: every call but the last is sent deferred, and a change so sent answers before its rebuild (build: "held", its result and bodies the part as last built; a pick by index names a face of that part); the last call builds them all, and so does a read of the part, or any other kind of tool, met on the way. timings: true gives each call its ms and the batch total_ms, and a change\'s build the steps it played with their ms (result.steps.played). wait: false answers at once with { queued, job }, and so does a batch still running at the server’s answer limit (with after, its seconds); scanruler_wait { for: "job", job } gives the batch\'s answer. A { "$file": path } anywhere in a call’s arguments is that JSON file’s contents. ${relativeFrom(cwd)} A tool that returns a picture is not shown here: the picture is written to a file — its call’s path, or a new file in the folder the scan open now was opened from (by scan_open or project_load with a path), else in ${outDir} — and its result gives that file.path; call the tool directly to see the picture. The server’s own tools (scanruler_status, _open, _wait, _batch, _write, _files, _tools) cannot be batched.`,
879
1202
  inputSchema: {
880
1203
  type: 'object',
881
1204
  properties: {
@@ -894,7 +1217,7 @@ export function createMcpServer({
894
1217
  additionalProperties: false,
895
1218
  },
896
1219
  },
897
- file: { type: 'string', minLength: 1, description: `A JSON file of the calls, read here in place of calls: [{ tool, arguments }], or [{ name, input }] by the command's own name (scan.open), or either under "calls" or "steps". For a long run a script wrote, which then need not pass through the conversation. A relative path is taken from ${cwd}.` },
1220
+ file: { type: 'string', minLength: 1, description: `A JSON file of the calls, read here in place of calls: [{ tool, arguments }], or [{ name, input }] by the command's own name (scan.open), or either under "calls" or "steps". For a long run a script wrote, which then need not pass through the conversation. ${relativeFrom(cwd)}` },
898
1221
  stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
899
1222
  rollback: { type: 'boolean', description: 'When the batch stops at a refused change, undo what it changed before it (the default) — or leave those changes standing (false). A refused read undoes nothing either way.' },
900
1223
  build: { type: 'string', enum: ['each', 'end'], description: 'each (the default): a change rebuilds the part before it answers. end: the changes build once, at the last call.' },
@@ -905,7 +1228,9 @@ export function createMcpServer({
905
1228
  },
906
1229
  annotations: { readOnlyHint: false, openWorldHint: false },
907
1230
  call: async (args, ctx) => {
908
- let calls = Array.isArray(args?.calls) ? args.calls : []
1231
+ const given = callsGiven(args?.calls)
1232
+ if (given.error) return failure({ code: 'invalid_input', message: given.error })
1233
+ let calls = given.calls
909
1234
  if (args?.file !== undefined) {
910
1235
  if (calls.length > 0) return failure({ code: 'invalid_input', message: 'Give calls or file, not both.' })
911
1236
  const read = await callsFromFile(String(args.file))
@@ -914,6 +1239,10 @@ export function createMcpServer({
914
1239
  }
915
1240
  if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call (or a file of them).' })
916
1241
  if (ctx) ctx.fields.calls = calls.length
1242
+ // Two calls naming new things alike would roll the batch back at the
1243
+ // second, over every good change before it: refused before any runs.
1244
+ const clash = nameClash(calls, (tool) => commands().find((c) => toolName(c) === tool))
1245
+ if (clash) return failure({ code: 'invalid_input', message: clash })
917
1246
  const job = startJob('scanruler_batch', (id) => runBatch(calls, args, ctx, id), ctx, args?.wait === false)
918
1247
  return args?.wait === false ? queuedReply(job) : answerFor(job, ctx)
919
1248
  },
@@ -923,7 +1252,7 @@ export function createMcpServer({
923
1252
  * batch takes them, or { name, input } by the command's own name, a
924
1253
  * list or one under "calls" or "steps". */
925
1254
  const callsFromFile = async (file) => {
926
- const path = absolute(file, cwd)
1255
+ const path = absolute(file, pathBase())
927
1256
  let data
928
1257
  try {
929
1258
  data = JSON.parse(await readFile(path, 'utf8'))
@@ -932,7 +1261,9 @@ export function createMcpServer({
932
1261
  }
933
1262
  const list = Array.isArray(data) ? data : Array.isArray(data?.calls) ? data.calls : Array.isArray(data?.steps) ? data.steps : null
934
1263
  if (!list) return { error: `file: ${path} holds no list of calls — a list, or one under "calls" or "steps".` }
935
- const calls = list.map((c) => (c && typeof c === 'object' && typeof c.name === 'string' && c.tool === undefined ? { tool: toolName({ name: c.name }), arguments: c.input ?? {} } : c))
1264
+ const given = callsGiven(list.map((c) => (c && typeof c === 'object' && typeof c.name === 'string' && c.tool === undefined ? { tool: toolName({ name: c.name }), arguments: c.input ?? {} } : c)))
1265
+ if (given.error) return { error: `file: ${path}: ${given.error}` }
1266
+ const { calls } = given
936
1267
  if (calls.some((c) => !c || typeof c !== 'object' || typeof c.tool !== 'string')) return { error: `file: every call in ${path} needs a tool (or a name).` }
937
1268
  return { calls }
938
1269
  }
@@ -977,7 +1308,7 @@ export function createMcpServer({
977
1308
  const hiddenAt = hiddenNow()
978
1309
  const deferred = atEnd && i < calls.length - 1
979
1310
  lastDeferred = deferred
980
- const run = () => runPageTool(tool, callArgs, undefined, { timings, batched: true, job, deferred })
1311
+ const run = () => runPageTool(tool, callArgs, (line) => noteProgress(jobs.get(job), line, i), { timings, batched: true, job, deferred })
981
1312
  let one = own.some((t) => t.name === tool) ? failure({ code: 'invalid_input', message: `${tool} is the server’s own and is not batched — call it on its own.` }) : await run()
982
1313
  // Busy with something of the page's own — a map being worked out,
983
1314
  // say: once it is idle, the call is run again, once.
@@ -1026,22 +1357,40 @@ export function createMcpServer({
1026
1357
  rollbackError = `${e?.code ?? 'failed'}: ${e?.message ?? String(e)}`
1027
1358
  }
1028
1359
  }
1360
+ // Each change taken back says so: its result is what it did before the
1361
+ // rollback, which no longer stands.
1362
+ let callsUndone = 0
1363
+ if (rollback && undone !== undefined) {
1364
+ for (let i = 0; i < results.length - 1; i++) {
1365
+ const { tool, ok, ...rest } = results[i]
1366
+ if (!ok || !changes(tool)) continue
1367
+ results[i] = { tool, ok: false, undone: true, ...rest }
1368
+ callsUndone++
1369
+ }
1370
+ }
1029
1371
  // Stopped before its last call, a batch built at the end leaves no
1030
1372
  // rebuild held: the page, read for idle, builds what is.
1031
1373
  if (atEnd && lastDeferred && !rollback) await pageIdle(Date.now() + IDLE_WAIT_MS)
1032
1374
  const kept = failed && checkpoint && !rollback ? { kept: `${failed.tool} changes nothing, so its refusal undid nothing: what the batch changed before it stands.` } : {}
1375
+ // The notes for the agent as the last call that gave them said — its
1376
+ // result spilled to a file, from its brief — at the top: not left in
1377
+ // the middle of a long batch, and kept in the batch's own brief.
1378
+ const notes = results.reduce((last, r) => notesIn(r.result) ?? notesIn(r.result?.brief) ?? last, undefined)
1033
1379
  const answer = {
1034
1380
  results,
1035
1381
  ran: results.length,
1036
1382
  of: calls.length,
1037
1383
  ...(stopped !== undefined ? { stopped } : {}),
1038
- ...(failed ? { failed, ...(rollback ? { undone: undone ?? [] } : {}), ...kept, ...(rollbackError ? { rollbackError } : {}) } : {}),
1384
+ ...(failed ? { failed, ...(rollback ? { undone: undone ?? [], callsUndone } : {}), ...kept, ...(rollbackError ? { rollbackError } : {}) } : {}),
1385
+ ...(notes !== undefined ? { notes } : {}),
1039
1386
  ...(timings ? { total_ms: Date.now() - started } : {}),
1040
1387
  }
1041
1388
  // Each call's result is capped already; many together may still be
1042
1389
  // past it — then the whole goes to a file, and the answer keeps how
1043
- // the batch ended.
1044
- return text(await fitted(answer, 'scanruler_batch', exact, ['failed', 'undone']), exact)
1390
+ // the batch ended. A call that may answer longer (a guide) lets the
1391
+ // batch it is in answer as long.
1392
+ const cap = Math.max(...calls.map((c) => capOf(commandOf(String(c?.tool ?? '')))))
1393
+ return text(await fitted(answer, 'scanruler_batch', exact, ['failed', 'undone'], { cap }), exact)
1045
1394
  }
1046
1395
 
1047
1396
  // The page's tools for a client that does not list them: a line each,
@@ -1083,7 +1432,7 @@ export function createMcpServer({
1083
1432
  for (const name of names) {
1084
1433
  const command = list.find((c) => toolName(c) === name)
1085
1434
  const mine = own.find((t) => t.name === name)
1086
- if (command) found.push(toolFor(command, { outDir }))
1435
+ if (command) found.push(toolFor(command, { outDir, cwd }))
1087
1436
  else if (mine) found.push(definition(mine))
1088
1437
  else unknown.push(name)
1089
1438
  }
@@ -1099,13 +1448,13 @@ export function createMcpServer({
1099
1448
  // lines of their own.
1100
1449
  let callNo = 0
1101
1450
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1102
- if (!trace) return dispatch(request, extra, null)
1451
+ if (!trace) return withReload(await dispatch(request, extra, null))
1103
1452
  const { name, arguments: args } = request.params
1104
1453
  const ctx = { call: ++callNo, fields: {} }
1105
1454
  const t = trace.now()
1106
1455
  const hiddenAt = hiddenNow()
1107
1456
  const running = runningJobs()
1108
- const reply = await dispatch(request, extra, ctx)
1457
+ const reply = withReload(await dispatch(request, extra, ctx))
1109
1458
  trace.write({
1110
1459
  ev: 'call',
1111
1460
  call: ctx.call,
@@ -1127,15 +1476,16 @@ export function createMcpServer({
1127
1476
  const { name, arguments: args } = request.params
1128
1477
  const mine = own.find((t) => t.name === name)
1129
1478
  if (mine) return mine.call(args, ctx)
1130
- // Progress lines from the page, for a client that asked for them.
1479
+ // Progress lines from the page: kept on the call's job, for the status
1480
+ // and scanruler_wait to answer, and sent on to a client that asked for
1481
+ // them.
1131
1482
  const token = request.params._meta?.progressToken
1132
1483
  let step = 0
1133
- const onProgress =
1134
- token === undefined
1135
- ? undefined
1136
- : (line) => {
1137
- extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
1138
- }
1484
+ let job = null
1485
+ const onProgress = (line) => {
1486
+ noteProgress(job, line)
1487
+ if (token !== undefined) extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
1488
+ }
1139
1489
  // A change runs as a job, and so does a long read: answered when done —
1140
1490
  // or with the job, at once for wait: false and at the answer limit
1141
1491
  // otherwise — and kept either way, for a client that stops waiting
@@ -1144,7 +1494,7 @@ export function createMcpServer({
1144
1494
  if (!command || (command.readOnly && !command.long)) return runPageTool(name, args, onProgress)
1145
1495
  const { wait, timings, ...rest } = args ?? {}
1146
1496
  const timed = timings === true && !command.readOnly
1147
- const job = startJob(name, (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id }), ctx, wait === false)
1497
+ job = startJob(name, (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id }), ctx, wait === false)
1148
1498
  return wait === false ? queuedReply(job) : answerFor(job, ctx)
1149
1499
  }
1150
1500