scanruler-mcp 0.5.0 → 0.6.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/src/mcp.js CHANGED
@@ -1,18 +1,22 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // The MCP side: the tools an agent lists and calls. Each of the page's
3
- // commands is a tool (tools.js); six more are this server's own —
3
+ // commands is a tool (tools.js); seven more are this server's own —
4
4
  // scanruler_status, to see whether a ScanRuler page is connected and how to
5
5
  // connect one; scanruler_open, to open one in the user's browser, paired;
6
6
  // scanruler_wait, to wait for it or for it to be idle; scanruler_batch, to
7
7
  // run several of the page's tools in one call; scanruler_write, to write a
8
- // text file — a report — next to the scan; and scanruler_files, to list
9
- // what is there. In compact mode, for a model with a small context window,
10
- // the page's tools are not listed but for those that return a picture,
8
+ // text file — a report — next to the scan; scanruler_files, to list what is
9
+ // there; and scanruler_read, to read a text file of it, an answer too long
10
+ // to carry among them. In compact mode, for a model with a small context
11
+ // window, the page's tools are not listed but for those that return a picture,
11
12
  // which a batch cannot show — it writes the picture to a file and gives its
12
13
  // text: one more of the server's own, scanruler_tools, lists them a line
13
14
  // each and gives the definitions of those asked for, and scanruler_batch
14
- // runs them. Headless (engine.js), the tools are the commands ScanRuler
15
- // runs in this process with no page, listed in either mode.
15
+ // runs them. With a list of namespaces (--tools scan,view or
16
+ // -flat,-thickness), only the commands of those namespaces, or all but
17
+ // those, are tools, beside the server's own; the others are unknown here.
18
+ // Headless (engine.js), the tools are the commands ScanRuler runs in this
19
+ // process with no page, listed in any mode.
16
20
  //
17
21
  // No call is held past the answer limit (`answerWithinMs`): a client, or a
18
22
  // bridge between it and this computer, may cut a call at a minute. A change
@@ -20,12 +24,12 @@
20
24
 
21
25
  import { randomBytes } from 'node:crypto'
22
26
  import { readFileSync } from 'node:fs'
23
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
27
+ import { mkdir, open, readdir, readFile, stat, writeFile } from 'node:fs/promises'
24
28
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
25
29
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
26
30
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
27
- import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles, writeNew } from './tools.js'
28
- import { jsonBytes, replyMeasure } from './trace.js'
31
+ import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, TEXT_FILES, toolFor, toolName, waitIn, withFiles, wholeUtf8, writeNew } from './tools.js'
32
+ import { jsonBytes, replyHead, replyMeasure } from './trace.js'
29
33
 
30
34
  /** The app's own commands as of this release, so the tools are there before
31
35
  * a page connects — unless a page has connected before whose list was kept
@@ -75,9 +79,46 @@ export function listBeforePage(kept, snapshot = SNAPSHOT, of = SNAPSHOT_OF) {
75
79
  }
76
80
 
77
81
  /** How the page's commands are listed: `full`, each its own tool, or
78
- * `compact`, read through scanruler_tools. */
82
+ * `compact`, read through scanruler_tools. A third way is a list of
83
+ * namespaces (parseToolMode). */
79
84
  export const TOOL_MODES = ['full', 'compact']
80
85
 
86
+ /** The namespace of a command's tool: the part of its name before the
87
+ * first underscore — scan for scan_open, view for view_render. */
88
+ const namespaceOf = (command) => toolName(command).split('_')[0]
89
+
90
+ /** How `--tools` reads. `full` and `compact` as they are; otherwise a
91
+ * comma-separated list of namespaces, the part of a tool's name before
92
+ * its first underscore: plain (`scan,view`) lists those namespaces alone,
93
+ * each with a leading `-` (`-flat,-thickness`) lists every namespace but
94
+ * those — and the server's own tools either way, each listed command a
95
+ * tool of its own as in full. A list that mixes the two forms, or is
96
+ * empty, throws, its message starting with `flag` (`--tools` or
97
+ * `SCANRULER_MCP_TOOLS`). A namespace no page has is not refused: a page
98
+ * may add it; the server says at a page's connection which entries match
99
+ * none of its namespaces. Returns { text, compact, named, lists }: `text`
100
+ * is the mode as given, what the status and serverInfo report; `named`
101
+ * the namespaces in the list (null for full and compact); `lists(ns)`
102
+ * whether a command of that namespace is listed. */
103
+ export function parseToolMode(given, flag = '--tools') {
104
+ const text = String(given ?? '').trim()
105
+ if (TOOL_MODES.includes(text)) return { text, compact: text === 'compact', named: null, lists: () => true }
106
+ const forms = `${TOOL_MODES.join(', ')}, namespaces to list (scan,view) or namespaces to leave out (-flat,-thickness)`
107
+ if (text === '') throw new Error(`${flag} is empty: give ${forms}.`)
108
+ const entries = text.split(',').map((s) => s.trim())
109
+ const bad = entries.find((e) => !/^-?[A-Za-z0-9]+$/.test(e))
110
+ if (bad !== undefined) {
111
+ throw new Error(`${flag} ${text}: "${bad}" is no namespace — a namespace is the part of a tool's name before its first underscore (scan, view, element …), with a leading - to leave it out. Give ${forms}.`)
112
+ }
113
+ const out = entries.filter((e) => e.startsWith('-'))
114
+ if (out.length > 0 && out.length < entries.length) {
115
+ throw new Error(`${flag} ${text} mixes namespaces to list (${entries.filter((e) => !e.startsWith('-')).join(', ')}) with namespaces to leave out (${out.join(', ')}): give one form — scan,view lists those alone, -flat,-thickness lists all but those.`)
116
+ }
117
+ const named = new Set(entries.map((e) => e.replace(/^-/, '')))
118
+ const exclude = out.length > 0
119
+ return { text, compact: false, named, lists: (ns) => named.has(ns) !== exclude }
120
+ }
121
+
81
122
  /** The most definitions scanruler_tools gives in one answer: twenty are
82
123
  * some ten thousand tokens, a small window's fair share. */
83
124
  const MAX_DEFINITIONS = 20
@@ -294,6 +335,11 @@ const FOLDERS_KEPT = 10
294
335
  /** How many entries scanruler_files lists at most. */
295
336
  const FILES_LISTED = 300
296
337
 
338
+ /** The folder in the scan's folder that the server's own files go into
339
+ * when no path was given — answers too long to carry, a batch's pictures
340
+ * — kept apart from the person's files beside the scan. */
341
+ const WORK_FOLDER = 'scanruler-work'
342
+
297
343
  const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
298
344
 
299
345
  /** The notes an answer carries for the agent (`notes`), when it is an
@@ -328,13 +374,16 @@ const failure = (e) => {
328
374
  * openPage?: () => Promise<boolean>,
329
375
  * openGraceMs?: number,
330
376
  * openWaitMs?: number,
331
- * tools?: 'full' | 'compact',
377
+ * tools?: string,
378
+ * log?: (line: string) => void,
332
379
  * trace?: ReturnType<typeof import('./trace.js').createTrace>,
333
380
  * answerWithinMs?: number,
334
381
  * folderStore?: { load(): string[] | null, save(folders: string[]): void } | null,
335
382
  * listWaitMs?: number,
336
383
  * answerCap?: number,
337
- * }} options — `tools` is how the page's commands are listed (TOOL_MODES).
384
+ * }} options — `tools` is how the page's commands are listed: full,
385
+ * compact, or a list of namespaces (parseToolMode). `log` takes the
386
+ * server's lines for the user — a namespace listed that no page has.
338
387
  * `listWaitMs`: how long a listing waits for a page that connected before
339
388
  * and is not back yet. `answerCap`: the most characters an answer
340
389
  * carries; a longer one goes to a file in the folder the scan open now
@@ -364,6 +413,7 @@ export function createMcpServer({
364
413
  openGraceMs = 11_000,
365
414
  openWaitMs = 40_000,
366
415
  tools: toolMode = 'full',
416
+ log = (line) => process.stderr.write(`[scanruler-mcp] ${line}\n`),
367
417
  trace = null,
368
418
  answerWithinMs = ANSWER_WITHIN_MS,
369
419
  folderStore = null,
@@ -374,10 +424,19 @@ export function createMcpServer({
374
424
  const holdMs = answerWithinMs > 0 ? answerWithinMs : Infinity
375
425
  const holdS = answerWithinMs > 0 ? answerWithinMs / 1000 : null
376
426
  const headless = link.backend === 'headless'
377
- const compact = toolMode === 'compact'
427
+ // Full, compact, or the namespaces to list: a command of a namespace
428
+ // left out is not listed, and not run either.
429
+ const selection = parseToolMode(toolMode)
430
+ const compact = selection.compact
431
+ const leftOut = (c) => !compact && !selection.lists(namespaceOf(c))
378
432
  const server = new Server(
379
433
  { name: 'scanruler', title: 'ScanRuler', version },
380
- { capabilities: { tools: { listChanged: true } }, instructions: (headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] },
434
+ {
435
+ capabilities: { tools: { listChanged: true } },
436
+ instructions:
437
+ (headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] +
438
+ (selection.named ? ` This server lists ScanRuler’s tools by namespace, as it was started (--tools ${selection.text}); a tool of a namespace left out is unknown here.` : ''),
439
+ },
381
440
  )
382
441
 
383
442
  // The last connected page's commands, for listing before a page is back:
@@ -422,7 +481,7 @@ export function createMcpServer({
422
481
  job.reply = reply
423
482
  // Answered to the client: seen, unless queued or likely timed out.
424
483
  job.told = !job.queued && job.ms < SEEN_WITHIN_MS
425
- if (job.queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply) })
484
+ if (job.queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply), ...(trace.args ? { head: replyHead(reply) } : {}) })
426
485
  return reply
427
486
  }
428
487
  job.done = Promise.resolve()
@@ -491,7 +550,7 @@ export function createMcpServer({
491
550
  job: job.job,
492
551
  tool: job.tool,
493
552
  after: holdS,
494
- note: `Still running after ${holdS} s, and running on. scanruler_wait { for: "job", job: "${job.job}" } gives its answer — call it again while it says running.`,
553
+ note: `Still running after ${holdS} s, and running on${hiddenNow() ? '; the ScanRuler tab is hidden' : ''}. scanruler_wait { for: "job", job: "${job.job}" } gives its answer — call it again while it says running.`,
495
554
  })
496
555
  }
497
556
 
@@ -541,7 +600,7 @@ export function createMcpServer({
541
600
  ...(o.resultOmitted ? { resultOmitted: o.resultOmitted } : {}),
542
601
  ...(o.error ? { error: o.error } : {}),
543
602
  }))
544
- const from = `the page (session.recent): this server copy, started at ${startedAt}, did not start ${job}; the page kept what was run under it.`
603
+ const from = `the page (session.recent): this server copy, started at ${startedAt}, did not start ${job}; the page kept what was run under it, but no picture or file it gave.`
545
604
  if (calls.length === 1) return { job, ...calls[0], from }
546
605
  return {
547
606
  job,
@@ -555,6 +614,9 @@ export function createMcpServer({
555
614
  /** Whether the tab is hidden now — null with no page, or headless. */
556
615
  const hiddenNow = () => (link.connected && link.page && !headless ? (link.page.hidden ?? false) : null)
557
616
  const runningJobs = () => [...jobs.values()].filter((j) => j.state === 'running').length
617
+ /** The page's reads being answered now, which no job stands for: an export
618
+ * writing its file, a check — what a wait for idle has to count too. */
619
+ const callsInFlight = new Set()
558
620
 
559
621
  /** A finished job as scanruler_wait { for: "idle" } lists it: a failed
560
622
  * one with its error, a batch with the call it stopped at. */
@@ -609,6 +671,14 @@ export function createMcpServer({
609
671
  if (state && kept.scan !== null && (state.scan?.fileName ?? null) !== kept.scan) return null
610
672
  return kept.folder
611
673
  }
674
+ /** The folder in the scan's folder the server's own files go into — an
675
+ * answer too long to carry, a batch's picture — so they do not lie
676
+ * among the person's: WORK_FOLDER, or null as scanFolderNow. Still in
677
+ * the scan's folder, which a sandboxed agent can open. */
678
+ const workFolderNow = async () => {
679
+ const folder = await scanFolderNow()
680
+ return folder ? join(folder, WORK_FOLDER) : null
681
+ }
612
682
 
613
683
  // ---- the page that came back ----------------------------------------------
614
684
  // A page names itself — pageId, new each time it loads — and says when it
@@ -701,9 +771,12 @@ export function createMcpServer({
701
771
  // An answer past answerCap — a read with every point in it, a batch of
702
772
  // many — would crowd out the agent's context: it is written whole to a
703
773
  // 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.
774
+ // goes into WORK_FOLDER in the folder the scan open now came from
775
+ // (workFolderNow) — a sandboxed agent can open that one, not the server's
776
+ // outDir — else, or when that folder takes no file, into outDir.
777
+ /** The answers this copy wrote to a file: scanruler_read reads them
778
+ * wherever they lie, outDir being no folder of a scan's. */
779
+ const spilled = new Set()
707
780
  /** The most characters a command's answer carries (ANSWER_CAPS), never
708
781
  * under answerCap. */
709
782
  const capOf = (command) => Math.max(answerCap, ANSWER_CAPS[command?.name] ?? 0)
@@ -718,16 +791,17 @@ export function createMcpServer({
718
791
  if (body.length <= cap) return value
719
792
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
720
793
  const name = `${tool}-${stamp}.json`
721
- const scanFolder = await scanFolderNow()
794
+ const workFolder = await workFolderNow()
722
795
  let file
723
796
  let where
724
797
  try {
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'}.`
798
+ file = workFolder ? await writeNew(workFolder, name, body).catch(() => null) : null
799
+ where = file ? `It is in ${workFolder}, ${WORK_FOLDER} in the folder the scan open now was opened from.` : `It is in ${outDir}, the server's output folder${workFolder ? ` — ${workFolder}, in the scan's folder, took no file` : ': the scan open now was not opened from a folder through this server'}.`
727
800
  file ??= await writeNew(outDir, name, body)
728
801
  } catch {
729
802
  return value
730
803
  }
804
+ spilled.add(resolve(file))
731
805
  const object = value !== null && typeof value === 'object' && !Array.isArray(value)
732
806
  const short = (k, v) => keep.includes(k) || k === 'notes' || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
733
807
  const brief = object ? Object.fromEntries(Object.entries(value).filter(([k, v]) => short(k, v))) : {}
@@ -742,15 +816,16 @@ export function createMcpServer({
742
816
  bytes: Buffer.byteLength(body, 'utf8'),
743
817
  ...(object ? { keys: Object.keys(value) } : Array.isArray(value) ? { items: value.length } : {}),
744
818
  ...(Object.keys(brief).length ? { brief } : {}),
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}`,
819
+ 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${headless ? '' : ' — scanruler_read { path: file } gives it in parts, where you cannot open the folder —'} or ask for less next time (${less}). ${where}`,
746
820
  }
747
821
  }
748
822
 
749
823
  // ---- folders -------------------------------------------------------------
750
824
  // The folders this server opened a scan or a project from, or saved one
751
- // into, the latest first: where scanruler_write may write and what
752
- // scanruler_files lists. Kept beside the command list (folderStore), so a
753
- // copy started afresh — or the other copy beside this one — has them too.
825
+ // into, the latest first: where scanruler_write may write, and what
826
+ // scanruler_files lists and scanruler_read reads. Kept beside the command
827
+ // list (folderStore), so a copy started afresh — or the other copy beside
828
+ // this one — has them too.
754
829
  let folders = []
755
830
  const knownFolders = () => {
756
831
  try {
@@ -839,7 +914,7 @@ export function createMcpServer({
839
914
  {
840
915
  name: 'scanruler_status',
841
916
  title: 'ScanRuler connection status',
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.',
917
+ 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, scanruler_files lists and scanruler_read reads; 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 without a path, go into scanruler-work in the folder the scan open now was opened from, when it was opened here by a path — the server’s own files, kept apart from the person’s (scanruler_files { path: "scanruler-work" } lists them, scanruler_read reads them). 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.',
843
918
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
844
919
  annotations: { readOnlyHint: true, openWorldHint: false },
845
920
  call: async () => {
@@ -851,7 +926,7 @@ export function createMcpServer({
851
926
  {
852
927
  name: 'scanruler_open',
853
928
  title: 'Open ScanRuler',
854
- description: 'Open ScanRuler in the user’s default browser, paired with this server, and wait for the page to connect — for when scanruler_status says none is. The first time, the browser asks the user whether the site may reach apps on this computer; they must allow it. Returns the status, with opened: whether the browser was asked to open it. If connected is still false, tell the user what howToConnect says.',
929
+ description: 'Open ScanRuler in the user’s default browser, paired with this server, and wait for the page to connect — for when scanruler_status says none is. The first time, ScanRuler warns the user that what you read goes to you and your model, and connects only once they accept; then the browser asks whether the site may reach apps on this computer, and they must allow it. Returns the status, with opened: whether the browser was asked to open it. If connected is still false, tell the user what howToConnect says.',
855
930
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
856
931
  annotations: { readOnlyHint: false, openWorldHint: false },
857
932
  call: async () => {
@@ -867,7 +942,7 @@ export function createMcpServer({
867
942
  {
868
943
  name: 'scanruler_wait',
869
944
  title: 'Wait for ScanRuler',
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.`,
945
+ 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 picture the job's tool gave comes with that answer, as an image. A job of an earlier copy of this server is read from the page's record of it (session.recent), from says so; that keeps no picture.${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.`,
871
946
  inputSchema: {
872
947
  type: 'object',
873
948
  properties: {
@@ -894,18 +969,20 @@ export function createMcpServer({
894
969
  await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
895
970
  clearTimeout(timer)
896
971
  if (j.state !== 'running') j.told = true
897
- return text(await fitted(jobAnswer(j), 'scanruler_wait'))
972
+ // The picture the job's tool gave, as the call itself would show it.
973
+ const answer = text(await fitted(jobAnswer(j), 'scanruler_wait'))
974
+ return { ...answer, content: [...answer.content, ...(j.reply?.content ?? []).filter((x) => x.type === 'image')] }
898
975
  }
899
976
  if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
900
977
  if (args?.for === 'idle') {
901
978
  // The changes still running first — their answers are what idle
902
979
  // waits for — then the page's own work.
903
- for (const j of [...jobs.values()].filter((x) => x.state === 'running')) {
980
+ for (const done of [...[...jobs.values()].filter((x) => x.state === 'running').map((x) => x.done), ...callsInFlight]) {
904
981
  let timer
905
- await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
982
+ await Promise.race([done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
906
983
  clearTimeout(timer)
907
984
  }
908
- const idle = (await pageIdle(until)) && runningJobs() === 0
985
+ const idle = (await pageIdle(until)) && runningJobs() === 0 && callsInFlight.size === 0
909
986
  // What finished since it was last said: each job once, a failed
910
987
  // one with its error.
911
988
  const finished = [...jobs.values()].filter((j) => j.state !== 'running' && !j.told)
@@ -967,6 +1044,29 @@ export function createMcpServer({
967
1044
  }
968
1045
  },
969
1046
  })
1047
+ own.push({
1048
+ name: 'scanruler_read',
1049
+ title: 'Read a text file beside the scan',
1050
+ description: `A text file read part by part: ${TEXT_FILES.join(', ')}, in the folder this server last opened a scan or a project from, or saved one into, in a folder under it, or in one of the folders scanruler_status lists — or an answer too long to carry (truncated: true, file), wherever it was written. A relative path is taken in the first of those folders, and a path may not climb out with "..". Returns { text, offset, nextOffset, size }, in bytes of the file: nextOffset, when there, is where to read on from; without it the text reaches the file's end. An answer holds at most ${Math.round(answerCap / 1000)} k characters. Refused for any other kind of file, and, but for such an answer, while no scan or project has been opened from a folder.`,
1051
+ inputSchema: {
1052
+ type: 'object',
1053
+ properties: {
1054
+ path: { type: 'string', minLength: 1, description: 'The file: a name in the scan’s folder, a path under it ("scanruler-work/report.json"), or an absolute path.' },
1055
+ offset: { type: 'integer', minimum: 0, description: 'Where to start, in bytes: the last answer’s nextOffset. The file’s start by default.' },
1056
+ limit: { type: 'integer', minimum: 1, description: 'The most bytes to read, a whole character at least; as many as an answer holds by default, and never more.' },
1057
+ },
1058
+ required: ['path'],
1059
+ additionalProperties: false,
1060
+ },
1061
+ annotations: { readOnlyHint: true, openWorldHint: false },
1062
+ call: async (args) => {
1063
+ try {
1064
+ return text(await readText(args?.path, args?.offset, args?.limit))
1065
+ } catch (e) {
1066
+ return failure(e)
1067
+ }
1068
+ },
1069
+ })
970
1070
  }
971
1071
 
972
1072
  /** scanruler_files' list — see its description. */
@@ -1007,6 +1107,76 @@ export function createMcpServer({
1007
1107
  }
1008
1108
  }
1009
1109
 
1110
+ /** scanruler_read's part of a text file, kept to the folders a scan or a
1111
+ * project came from and the answers this copy wrote to a file — see its
1112
+ * description. Cut on a character, and short enough that its answer, its
1113
+ * JSON's escapes counted, stays within answerCap. */
1114
+ const readText = async (path, offset = 0, limit = answerCap) => {
1115
+ if (typeof path !== 'string' || path.length === 0) throw { code: 'invalid_input', message: 'path: give the file to read.' }
1116
+ if (!Number.isInteger(offset) || offset < 0) throw { code: 'invalid_input', message: 'offset: give a whole number of bytes, 0 or more — the last answer’s nextOffset.' }
1117
+ if (!Number.isInteger(limit) || limit < 1) throw { code: 'invalid_input', message: 'limit: give a whole number of bytes, 1 or more.' }
1118
+ if (fileKind(path) !== 'text') throw { code: 'invalid_input', message: `path: only ${TEXT_FILES.join(', ')} files are read, as text — not "${extname(path).toLowerCase() || 'no extension'}".` }
1119
+ if (path.split(/[\\/]/).includes('..')) throw { code: 'invalid_input', message: 'path: ".." is not taken — give a path in the scan’s folder or under it.' }
1120
+ const folders = knownFolders()
1121
+ const target = resolve(folders[0] ?? outDir, path)
1122
+ const inside = (folder) => {
1123
+ const r = relative(folder, target)
1124
+ return r !== '' && !r.startsWith('..') && !isAbsolute(r)
1125
+ }
1126
+ if (!spilled.has(target)) {
1127
+ if (folders.length === 0) throw { code: 'invalid_state', message: 'This server has opened no scan or project from a folder, so it has none to read from. Open the scan with scan_open (or a project with project_load) by its path first.' }
1128
+ if (!folders.some(inside)) throw { code: 'invalid_input', message: `path: ${target} is outside the folders a scan or a project was opened from — ${folders.join(', ')}.` }
1129
+ }
1130
+ const most = Math.min(limit, answerCap)
1131
+ let size
1132
+ let start
1133
+ let bytes
1134
+ let handle
1135
+ try {
1136
+ handle = await open(target, 'r')
1137
+ ;({ size } = await handle.stat())
1138
+ start = Math.min(offset, size)
1139
+ // Room to pass a character cut at the offset, and for a whole one after.
1140
+ const buffer = Buffer.alloc(Math.min(Math.max(4, most) + 3, size - start))
1141
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
1142
+ bytes = buffer.subarray(0, bytesRead)
1143
+ } catch (e) {
1144
+ throw { code: 'not_found', message: `${target} could not be read: ${e?.code === 'ENOENT' ? 'there is no such file' : (e?.message ?? String(e))}.` }
1145
+ } finally {
1146
+ await handle?.close()
1147
+ }
1148
+ // An offset in the middle of a character starts at the next one.
1149
+ for (let i = 0; i < 3 && start > 0 && (bytes[0] & 0xc0) === 0x80; i++) {
1150
+ bytes = bytes.subarray(1)
1151
+ start++
1152
+ }
1153
+ let chars
1154
+ try {
1155
+ // Nothing whole is a character the file itself ends inside: refused.
1156
+ const whole = wholeUtf8(bytes, most)
1157
+ chars = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(whole.length > 0 ? whole : bytes)
1158
+ } catch {
1159
+ throw { code: 'invalid_input', message: `path: ${target} does not read as UTF-8 text, so it may not be a text file.` }
1160
+ }
1161
+ // The most characters whose answer fits, a surrogate pair kept whole.
1162
+ const fits = (n) => agentJson({ text: chars.slice(0, n), offset: start, nextOffset: size, size }).length <= answerCap
1163
+ let count = chars.length
1164
+ if (!fits(count)) {
1165
+ let low = 0
1166
+ let high = count
1167
+ while (high - low > 1) {
1168
+ const mid = (low + high) >> 1
1169
+ if (fits(mid)) low = mid
1170
+ else high = mid
1171
+ }
1172
+ const last = chars.charCodeAt(low - 1)
1173
+ count = last >= 0xd800 && last < 0xdc00 ? low - 1 : low
1174
+ }
1175
+ const part = chars.slice(0, count)
1176
+ const next = start + Buffer.byteLength(part, 'utf8')
1177
+ return { text: part, offset: start, ...(next < size ? { nextOffset: next } : {}), size }
1178
+ }
1179
+
1010
1180
  /** scanruler_write's file, kept to the folders a scan or a project came
1011
1181
  * from — see its description. */
1012
1182
  const writeText = async (path, body) => {
@@ -1039,8 +1209,9 @@ export function createMcpServer({
1039
1209
  const definition = ({ call: _call, ...tool }) => tool
1040
1210
  // Compact, the list is the server's own tools and the page's that return
1041
1211
  // a picture — a batch gives a picture as its text and a file, so those are called
1042
- // directly; scanruler_tools reads commands() when it is called.
1043
- const shown = (c) => !compact || Boolean(c.returnsImage)
1212
+ // directly; scanruler_tools reads commands() when it is called. With
1213
+ // namespaces, the server's own and the page's of the namespaces listed.
1214
+ const shown = (c) => (compact ? Boolean(c.returnsImage) : !leftOut(c))
1044
1215
  const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir, cwd }))]
1045
1216
  const listSource = () =>
1046
1217
  link.page?.commands?.length ? `the page, ScanRuler ${link.page.version ?? 'of no version'}` : lastPage ? lastPage.from : `this server’s own list (scanruler-mcp ${version})`
@@ -1103,19 +1274,35 @@ export function createMcpServer({
1103
1274
  },
1104
1275
  }
1105
1276
  }
1277
+ // A namespace in --tools that none of the page's commands is in — a
1278
+ // typo, or a plugin the page lacks — said once per page, and again when
1279
+ // the page's commands change it.
1280
+ let unmatchedSaid = null
1281
+ const noteNamespaces = () => {
1282
+ if (!selection.named || !link.page?.commands?.length) return
1283
+ const have = new Set(link.page.commands.map(namespaceOf))
1284
+ const unmatched = [...selection.named].filter((ns) => !have.has(ns))
1285
+ const key = unmatched.join(',')
1286
+ if (key === unmatchedSaid) return
1287
+ unmatchedSaid = key
1288
+ if (unmatched.length === 0) return
1289
+ log(`--tools ${selection.text}: no tool of the ${headless ? 'engine' : 'page'} (ScanRuler ${link.page.version ?? 'of no version'}) is in ${unmatched.join(', ')}; its namespaces are ${[...have].sort().join(', ')}.`)
1290
+ }
1106
1291
  for (const event of ['connected', 'commands']) {
1107
1292
  link.on(event, () => {
1108
1293
  seePage(event === 'connected')
1109
1294
  remember()
1110
1295
  relist()
1296
+ noteNamespaces()
1111
1297
  })
1112
1298
  }
1113
1299
  seePage(true)
1300
+ noteNamespaces()
1114
1301
 
1115
1302
  /** One of the page's tools run on the page: the result as MCP content,
1116
1303
  * or a failure. `batched`: a picture is written to a file — the call's
1117
- * path, or a new one in the scan's folder (scanFolderNow), else in
1118
- * outDir — and
1304
+ * path, or a new one in WORK_FOLDER in the scan's folder (workFolderNow),
1305
+ * else in outDir — and
1119
1306
  * given as its text, not shown. `job`:
1120
1307
  * the job the call is part of, which the page keeps its outcome under.
1121
1308
  * `deferred`: more changes follow straight after it (a batch with
@@ -1126,6 +1313,9 @@ export function createMcpServer({
1126
1313
  const needsPage = !command && headless ? link.pageCommand(name) : null
1127
1314
  if (needsPage) return failure({ code: 'unavailable', message: `${name} needs the ScanRuler page — a scan, the viewport or the browser — and this server runs ScanRuler headless, with none. Start scanruler-mcp in page mode (without --engine or --headless) and pair it with a ScanRuler tab for it.` })
1128
1315
  if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
1316
+ if (leftOut(command)) {
1317
+ return failure({ code: 'unknown_command', message: `There is no tool ${name} here: this server was started with --tools ${selection.text}, which leaves the ${namespaceOf(command)}_ tools out. A server started with --tools full, or with a list that has ${namespaceOf(command)}, offers it.` })
1318
+ }
1129
1319
  if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
1130
1320
  return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
1131
1321
  }
@@ -1159,9 +1349,13 @@ export function createMcpServer({
1159
1349
  const { played: _played, ...steps } = result.steps
1160
1350
  result = { ...result, steps }
1161
1351
  }
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 })
1352
+ const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd: base, outDir, folder: args?.path ? null : await workFolderNow() }) : await saveFileResult(result, args, { cwd: base, outDir })
1163
1353
  // A project saved to a file: the one a reloaded page is pointed to.
1164
1354
  if (saved?.file?.path) rememberProject(saved.file.path)
1355
+ if (saved?.holds && saved?.file?.path) {
1356
+ const lost = lostSinceLastSave(saved)
1357
+ if (lost) saved.warning = saved.warning ? `${saved.warning} ${lost}` : lost
1358
+ }
1165
1359
  return text(await fitted(saved, name, Boolean(command.exact), [], { cap: capOf(command), ...(lessOf(command) ? { less: lessOf(command) } : {}) }), Boolean(command.exact))
1166
1360
  } catch (e) {
1167
1361
  // A page older than its app, said once so: the status keeps it.
@@ -1170,6 +1364,20 @@ export function createMcpServer({
1170
1364
  }
1171
1365
  }
1172
1366
 
1367
+ /** What the last save from this server held and this one does not — the
1368
+ * page was reset or reloaded between them, as its own memory cannot tell —
1369
+ * in a sentence; null when nothing fell away. */
1370
+ let lastSave = null
1371
+ const lostSinceLastSave = ({ holds, aligned, file }) => {
1372
+ const was = lastSave
1373
+ lastSave = { holds, aligned, path: file.path }
1374
+ if (!was) return null
1375
+ const gone = Object.entries(was.holds).filter(([what, n]) => n > 0 && !(holds[what] > 0)).map(([what, n]) => `${n} ${what}`)
1376
+ const frame = was.aligned && !aligned
1377
+ if (gone.length === 0 && !frame) return null
1378
+ return `This file holds less than the last save (${was.path}): ${[...gone, ...(frame ? ['the alignment'] : [])].join(', ')} gone — the page was reset or reloaded between them, so it is no copy of that work${frame ? ', and coordinates from before no longer hold on the scan' : ''}.`
1379
+ }
1380
+
1173
1381
  /** The session saved as a project file at `path` (a file, or a folder
1174
1382
  * for its own name): where it went, or null when the session held
1175
1383
  * nothing to save. Throws, saving nothing, when the save fails. */
@@ -1191,14 +1399,14 @@ export function createMcpServer({
1191
1399
  // Several of the page's tools in one call — what cuts a run of twenty
1192
1400
  // scan_nearest calls to one round trip. Each answer is the tool's own; a
1193
1401
  // picture comes back as the text beside it, not as an image, and is
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
1196
- // text names.
1402
+ // written to a file — the call's path, or a new one in WORK_FOLDER in the
1403
+ // folder the scan open now came from (workFolderNow), else in outDir —
1404
+ // which the text names.
1197
1405
  own.push({
1198
1406
  name: 'scanruler_batch',
1199
1407
  title: 'Run several ScanRuler tools',
1200
1408
  description:
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.`,
1409
+ `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 ${WORK_FOLDER} 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 (and file.folder, the ${WORK_FOLDER} folder); call the tool directly to see the picture. The server’s own tools (scanruler_status, _open, _wait, _batch, _write, _files, _read, _tools) cannot be batched.`,
1202
1410
  inputSchema: {
1203
1411
  type: 'object',
1204
1412
  properties: {
@@ -1319,7 +1527,7 @@ export function createMcpServer({
1319
1527
  retried = 'busy'
1320
1528
  }
1321
1529
  if (ctx && trace) {
1322
- trace.write({ ev: 'entry', call: ctx.call, index: i, t: at, tool, ms: Date.now() - at, in: jsonBytes(callArgs), ...replyMeasure(one), hidden: [hiddenAt, hiddenNow()], ...(retried ? { retried } : {}), ...(trace.args ? { args: callArgs } : {}) })
1530
+ trace.write({ ev: 'entry', call: ctx.call, index: i, t: at, tool, ms: Date.now() - at, in: jsonBytes(callArgs), ...replyMeasure(one), hidden: [hiddenAt, hiddenNow()], ...(retried ? { retried } : {}), ...(trace.args ? { args: callArgs, head: replyHead(one) } : {}) })
1323
1531
  }
1324
1532
  const ms = timings ? { ms: Date.now() - at } : {}
1325
1533
  const again = retried ? { retried } : {}
@@ -1466,7 +1674,7 @@ export function createMcpServer({
1466
1674
  hidden: [hiddenAt, hiddenNow()],
1467
1675
  ...(running ? { running } : {}),
1468
1676
  ...ctx.fields,
1469
- ...(trace.args ? { args: args ?? {} } : {}),
1677
+ ...(trace.args ? { args: args ?? {}, head: replyHead(reply) } : {}),
1470
1678
  })
1471
1679
  return reply
1472
1680
  })
@@ -1491,10 +1699,27 @@ export function createMcpServer({
1491
1699
  // otherwise — and kept either way, for a client that stops waiting
1492
1700
  // before it is done.
1493
1701
  const command = commands().find((c) => toolName(c) === name)
1494
- if (!command || (command.readOnly && !command.long)) return runPageTool(name, args, onProgress)
1702
+ if (!command || (command.readOnly && !command.long)) {
1703
+ const answer = runPageTool(name, args, onProgress)
1704
+ callsInFlight.add(answer)
1705
+ return answer.finally(() => callsInFlight.delete(answer))
1706
+ }
1495
1707
  const { wait, timings, ...rest } = args ?? {}
1496
1708
  const timed = timings === true && !command.readOnly
1497
- job = startJob(name, (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id }), ctx, wait === false)
1709
+ // Busy with something of the page's own, as a batch's calls are: once it
1710
+ // is idle, the call is run again, once.
1711
+ const runOnce = (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id })
1712
+ job = startJob(
1713
+ name,
1714
+ async (id) => {
1715
+ const first = await runOnce(id)
1716
+ if (replyMeasure(first).error !== 'busy') return first
1717
+ await pageIdle(Date.now() + IDLE_WAIT_MS)
1718
+ return runOnce(id)
1719
+ },
1720
+ ctx,
1721
+ wait === false,
1722
+ )
1498
1723
  return wait === false ? queuedReply(job) : answerFor(job, ctx)
1499
1724
  }
1500
1725