glasspane-mcp 1.20.2 → 1.21.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.
@@ -26,7 +26,7 @@ export declare const MCP_PROTOCOL_VERSION: "2025-06-18";
26
26
  export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly string[];
27
27
  export declare const SERVER_INFO: {
28
28
  readonly name: "glasspane-mcp";
29
- readonly version: "1.20.2";
29
+ readonly version: "1.21.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
package/dist/errors.d.ts CHANGED
@@ -76,6 +76,16 @@ export declare const GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
76
76
  * the filesystem root, the account's home directory itself, or a value that is not
77
77
  * an absolute path at all.
78
78
  *
79
+ * Since 1.20.1 this code also answers the *other* un-checked root in this shell: a
80
+ * `GLASSPANE_PROJECTS_FILE` override that names a directory `gp_project_set` would
81
+ * otherwise create and bring to 0700 before publishing the registry into it
82
+ * (`project-registry.ts`'s `overrideProjectsPath`). Same authority, same consequence, and
83
+ * no new vocabulary: both are "a root this process was told to write into, judged and
84
+ * refused before the first syscall". The two differ in who said it — one is the service's
85
+ * self-report, the other this process's own environment — and each frame carries its own
86
+ * remedy (`ProjectRegistryError.remedy`) so the reader is never told to restart the daemon
87
+ * for a variable it exported, or to edit a file for a refusal that names a directory.
88
+ *
79
89
  * The daemon refuses the same shapes for its own `--state-dir` (`StateRoot.classify`
80
90
  * → `StateRoot.notADedicatedRoot`) and then `tightenPermissions` that root to 0700,
81
91
  * so the argument parse is the only thing standing between `--state-dir ~` and a
@@ -378,8 +378,12 @@ function clientClosingRemedy(surface) {
378
378
  const readRoute = readInsteadRoute(surface);
379
379
  return `this shell's own engine client is ending, which measures nothing about the daemon: nothing here authorises touching the daemon's lifecycle, and no restart is authorised by this answer. What is unknown stays unknown: whether the daemon finished this request cannot be told from a client that stopped asking. The reply may still land, under the bound this shell already states \u2014 ${lateReplyRoute(surface)} \u2014 and this shutdown is one of the endings that bound names: the server log line written at this moment lists the requests this client stopped tracking. Read what happened rather than performing it a second time: ${readRoute}. Ask through a server process that still has a live engine client.`;
380
380
  }
381
- function transportDeathRemedy(method, surface) {
381
+ var NEVER_LEFT_TRANSPORT = "the frame this request wrote never left this process \u2014 its bytes were still in this client's own write queue when the transport went, so nothing reached the daemon and nothing was performed there";
382
+ function transportDeathRemedy(method, surface, handedOff) {
382
383
  const restore = daemonRestoreCommand();
384
+ if (!handedOff) {
385
+ return daemonUnreachableRemedy();
386
+ }
383
387
  const unsafe = REPLAY_UNSAFE_REASONS[method];
384
388
  if (unsafe !== void 0) {
385
389
  return `${restore}. Do NOT re-issue '${method}' on the strength of this answer: this request had already left this shell when its transport died, and whether the daemon carried it out before that cannot be told from here \u2014 a re-issue can perform it a second time, because ${unsafe}. Read what happened instead of performing it a second time: ${readInsteadRoute(surface)}.`;
@@ -433,10 +437,23 @@ var EngineJsonRpcClient = class {
433
437
  this.failOversizedReply(error);
434
438
  return;
435
439
  }
436
- this.teardown(`engine transport error: ${error.message}`, (method) => transportDeathRemedy(method, this.surface));
440
+ this.teardown(
441
+ `engine transport error: ${error.message}`,
442
+ (method, handedOff) => transportDeathRemedy(method, this.surface, handedOff),
443
+ NEVER_LEFT_TRANSPORT
444
+ );
445
+ });
446
+ this.io.onClose(() => this.teardown(
447
+ "engine transport closed",
448
+ (method, handedOff) => transportDeathRemedy(method, this.surface, handedOff),
449
+ NEVER_LEFT_TRANSPORT
450
+ ));
451
+ this.io.onOpen?.(() => {
452
+ for (const entry of this.pending.values()) {
453
+ entry.handedOff = true;
454
+ }
455
+ this.handshake();
437
456
  });
438
- this.io.onClose(() => this.teardown("engine transport closed", (method) => transportDeathRemedy(method, this.surface)));
439
- this.io.onOpen?.(() => this.handshake());
440
457
  }
441
458
  io;
442
459
  timeoutMs;
@@ -548,7 +565,7 @@ var EngineJsonRpcClient = class {
548
565
  id,
549
566
  method,
550
567
  correlation,
551
- params,
568
+ handedOff: true,
552
569
  deadlineMs,
553
570
  methodDeadlineMs,
554
571
  timer: null,
@@ -568,8 +585,14 @@ var EngineJsonRpcClient = class {
568
585
  return;
569
586
  }
570
587
  this.pending.set(id, entry);
588
+ const queuedBefore = this.io.bufferedBytes?.() ?? null;
571
589
  try {
572
- this.io.writeLine(JSON.stringify(frame));
590
+ const frameText = JSON.stringify(frame);
591
+ this.io.writeLine(frameText);
592
+ if (queuedBefore !== null) {
593
+ const queuedAfter = this.io.bufferedBytes?.() ?? 0;
594
+ entry.handedOff = queuedAfter < queuedBefore + frameText.length + 1;
595
+ }
573
596
  } catch (error) {
574
597
  this.pending.delete(id);
575
598
  this.reject(entry, new EngineCallError(
@@ -844,13 +867,14 @@ var EngineJsonRpcClient = class {
844
867
  * and one remedy for the whole batch is how "…, then retry" reached an
845
868
  * outstanding `act` at all. See {@link transportDeathRemedy}.
846
869
  */
847
- teardown(message, remedyFor) {
870
+ teardown(message, remedyFor, neverLeftNote = null) {
848
871
  const entries = [...this.pending.values()];
849
872
  const tracked = entries.filter((entry) => entry.settled);
850
873
  this.pending.clear();
851
874
  for (const entry of entries) {
852
875
  if (!entry.settled) {
853
- this.reject(entry, new EngineCallError(GP_E_ENGINE_UNREACHABLE, message, remedyFor(entry.method)));
876
+ const said = entry.handedOff || neverLeftNote === null ? message : `${message}; ${neverLeftNote}`;
877
+ this.reject(entry, new EngineCallError(GP_E_ENGINE_UNREACHABLE, said, remedyFor(entry.method, entry.handedOff)));
854
878
  } else if (entry.timer !== null) {
855
879
  clearTimeout(entry.timer);
856
880
  entry.timer = null;
@@ -893,6 +917,10 @@ var EngineJsonRpcClient = class {
893
917
  if (version !== "") {
894
918
  this.helloVersion = version;
895
919
  }
920
+ const reportedRoot = typeof report.stateRoot === "string" ? report.stateRoot.trim() : "";
921
+ if (reportedRoot !== "") {
922
+ this.helloStateRoot = reportedRoot;
923
+ }
896
924
  const protocol = typeof report.protocolVersion === "string" ? report.protocolVersion : null;
897
925
  if (protocol === null) {
898
926
  this.report(`engine self-report has no protocolVersion (engine=${jsonOrString(report.engine)}); expected '${expected}' \u2014 compatibility unverified`);
@@ -903,10 +931,6 @@ var EngineJsonRpcClient = class {
903
931
  return;
904
932
  }
905
933
  const reportedVersion = version === "" ? null : version;
906
- const reportedRoot = typeof report.stateRoot === "string" ? report.stateRoot.trim() : "";
907
- if (reportedRoot !== "") {
908
- this.helloStateRoot = reportedRoot;
909
- }
910
934
  const wantVersion = this.identity?.version;
911
935
  if (reportedVersion !== null && wantVersion !== void 0 && reportedVersion !== wantVersion) {
912
936
  this.report(`version mismatch: engine self-reports '${reportedVersion}', this MCP shell ships '${wantVersion}' (single version line) \u2014 one side is stale`);
@@ -2151,9 +2175,52 @@ var ProjectSetArgs = z9.strictObject({
2151
2175
  });
2152
2176
  function projectsPath(daemonStateRoot) {
2153
2177
  const envPath = process.env[PROJECTS_FILE_ENV];
2154
- if (envPath) return envPath;
2178
+ if (envPath) return overrideProjectsPath(envPath);
2155
2179
  return daemonProjectsPath(daemonStateRoot);
2156
2180
  }
2181
+ function homeDefaultStateRoot() {
2182
+ return stripFirmlink(path3.posix.normalize(systemHome() + "/.glasspane"));
2183
+ }
2184
+ function overrideProjectsPath(raw) {
2185
+ const trimmed = raw.trim();
2186
+ const normalized = foldSlashTerminators(stripFirmlink(path3.posix.normalize(trimmed)));
2187
+ if (!normalized.startsWith("/")) {
2188
+ throw refusedProjectsFileOverride(
2189
+ trimmed,
2190
+ `is not an absolute path (${trimmed}), so which projects file this write reaches moves with the working directory of whoever started this MCP server`
2191
+ );
2192
+ }
2193
+ for (const component of normalized.split("/")) {
2194
+ if (component === "." || component === "..") {
2195
+ throw refusedProjectsFileOverride(
2196
+ trimmed,
2197
+ `carries a "." or ".." component (${trimmed}), so the file it names is not the file this call was asked to write`
2198
+ );
2199
+ }
2200
+ }
2201
+ const directory = path3.dirname(normalized);
2202
+ if (directory === homeDefaultStateRoot()) {
2203
+ return normalized;
2204
+ }
2205
+ const shape = stateRootRefusal(directory);
2206
+ if (shape !== null) {
2207
+ throw refusedProjectsFileOverride(trimmed, `its directory ${shape} (${directory})`);
2208
+ }
2209
+ const resolved = stripFirmlink(resolveExistingAncestor(directory));
2210
+ const refusal = strongestRefusal(resolved === directory ? [directory] : [directory, resolved], resolved);
2211
+ if (refusal !== null) {
2212
+ throw refusedProjectsFileOverride(trimmed, `its directory ${refusalPhrase(refusal, resolved)}`);
2213
+ }
2214
+ return normalized;
2215
+ }
2216
+ function refusedProjectsFileOverride(raw, defect) {
2217
+ const defaultFile = `${homeDefaultStateRoot()}/projects.json`;
2218
+ return new ProjectRegistryError(
2219
+ GP_E_STATE_ROOT_REFUSED,
2220
+ `the ${PROJECTS_FILE_ENV} override this shell was started with ${defect}, so this shell refuses to use it: ${quoteShellArg(raw)}. This is the override slot of **this process**, not a root the background service reported about itself \u2014 the daemon never reads ${PROJECTS_FILE_ENV} (R7-14), so the value being judged is one an environment, a wrapper, or an agent set for the MCP shell alone. It is not a harmless pointer either: \`gp_project_set\` would have created that directory and re-permissioned it to 0700, then written the project registry \u2014 bundle ids, pids and storage paths \u2014 into it, which is the same authority this module refuses a self-reported root over (\`StateRoot.notADedicatedRoot\`) and refuses an agent-named evidence archive over (\`refuseCandidate\`). Nothing was read and nothing was written by this call: no directory was created, no permission was changed, no projects.json was opened, listed or saved.`,
2221
+ `this shell's own override is what is configured, not your arguments and not a damaged file: \`unset ${PROJECTS_FILE_ENV}\` for this MCP server process and repeat gp_project_set \u2014 it then writes the projects file of the root the running daemon named in its hello, or the home default \`${quoteShellArg(defaultFile)}\` when it named none. Alternatively point the override at one of those two paths; do not point it at a tree this shell will not create or re-permission, and do not repair a projects file for this refusal \u2014 the refusal names a directory, and no path under it was touched. If the service really is running on a root this shell cannot accept, move the service instead: \`glasspaned --state-dir <a directory this install owns>\`, then reconnect so a fresh hello reports it`
2222
+ );
2223
+ }
2157
2224
  function loadRegistry(filePath) {
2158
2225
  let bytes;
2159
2226
  try {
@@ -2353,13 +2420,22 @@ function saveProjects(filePath, entries) {
2353
2420
  if (error instanceof ProjectRegistryError) {
2354
2421
  throw new ProjectRegistryError(
2355
2422
  error.code,
2356
- `${error.message}; projects.json at ${filePath} was therefore not published as owner-only, and gp_project_set refuses to report a write the daemon's own modes did not hold. Bring the state root to them yourself (chmod 700 ${quoteShellArg(dir)}, chmod 600 ${quoteShellArg(filePath)}) and retry \u2014 a volume that ignores chmod (read-only mount, ACL, immutable flag) needs the daemon's --state-dir moved to one that honours permissions; until then the registry on disk and this shell disagree about who can read it`
2423
+ `${error.message}; projects.json at ${filePath} was therefore not published as owner-only, and gp_project_set refuses to report a write the daemon's own modes did not hold. Bring the state root to them yourself (chmod 700 ${quoteShellArg(dir)}, chmod 600 ${quoteShellArg(filePath)}) and retry \u2014 a volume that ignores chmod (read-only mount, ACL, immutable flag) needs the daemon's --state-dir moved to one that honours permissions; until then the registry on disk and this shell disagree about who can read it`,
2424
+ // The refusal above already names the two commands that make the modes true, so the
2425
+ // remedy transcribes them instead of sending the reader to repair a file nobody
2426
+ // called damaged (B5). Nothing here is a judgement about the registry's contents.
2427
+ `the mode this shell asked for did not stick, so nothing was published: bring it to the daemon's own modes yourself \u2014 \`chmod 700 ${quoteShellArg(dir)}\` and \`chmod 600 ${quoteShellArg(filePath)}\` \u2014 read them back with \`stat -f '%Sp %N' ${quoteShellArg(dir)} ${quoteShellArg(filePath)}\`, and repeat gp_project_set once they say 700 and 600. If chmod answers success and the mode stays loose, the volume ignores permissions: find out with \`ls -lO ${quoteShellArg(dir)}\` (an immutable flag) and \`diskutil info -live ${quoteShellArg(dir)}\` (a read-only or remote mount), and move the service onto a volume that honours them \`glasspaned --state-dir <a directory this install owns>\`). Do not repair or overwrite projects.json for this one, and do not set ${FORCE_OVERWRITE_ENV}: the fault is the mode, not the bytes in the file`
2357
2428
  );
2358
2429
  }
2359
2430
  const errno = error?.code ?? "unknown";
2360
2431
  throw new ProjectRegistryError(
2361
2432
  GP_E_INTERNAL,
2362
- `projects.json at ${filePath} was not published: committing the bytes to the device failed (${errno}). gp_project_set answers "not saved" here rather than "saved", because a registration a power loss can drop is not a registration. This is the write's own failure, not a verdict about the file's contents: check what the volume will do (\`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount), fix that, then retry the same call.`
2433
+ `projects.json at ${filePath} was not published: committing the bytes to the device failed (${errno}). gp_project_set answers "not saved" here rather than "saved", because a registration a power loss can drop is not a registration. This is the write's own failure, not a verdict about the file's contents: check what the volume will do (\`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount), fix that, then retry the same call.`,
2434
+ // Carried by the error rather than derived from `GP_E_INTERNAL` in `tools.ts` (B5):
2435
+ // that code's arm reads "the projects file is the problem … repair or restore the
2436
+ // damaged entry", which is the opposite of the sentence above and would send an agent
2437
+ // to edit a registry that this call never failed to decode.
2438
+ `nothing was published by this call, and the file's contents are not in question: check what the volume will do \u2014 \`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount, \`df -h ${quoteShellArg(dir)}\` for a full one \u2014 fix that, and repeat the same gp_project_set once the volume commits. Do not repair or restore projects.json for this fault, and do not set ${FORCE_OVERWRITE_ENV}: it moves an unreadable registry aside, which is a different problem from a write the device refused`
2363
2439
  );
2364
2440
  }
2365
2441
  }
@@ -2478,7 +2554,7 @@ function daemonRestartNotice(filePath, daemonStateRoot) {
2478
2554
  return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here, because this daemon's hello reported no stateRoot \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
2479
2555
  }
2480
2556
  function rootDiscriminator() {
2481
- return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
2557
+ return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json \u2014 this shell judges that override with the same path gates it judges every root it writes, because writing there means creating the directory and bringing it to 0700, so a shared scratch tree, a directory this user does not own, or a world-writable one is refused with " + GP_E_STATE_ROOT_REFUSED + " and nothing is written; such a service root is not a place this surface can register into, and the fix is to run the service on a root both sides accept): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
2482
2558
  }
2483
2559
  function restartCommand() {
2484
2560
  return `run \`launchctl kickstart -k gui/${currentUid()}/com.glasspane.daemon\` \u2014 if that reports the service was not found, the launchd job was never bootstrapped, so ${daemonUnreachableRemedy()}`;
@@ -2654,10 +2730,22 @@ function describeError(error) {
2654
2730
  }
2655
2731
  var ProjectRegistryError = class extends Error {
2656
2732
  code;
2657
- constructor(code, message) {
2733
+ /**
2734
+ * The registry layer's own next move, when this fault has one that the code cannot
2735
+ * state. `tools.ts` transcribes it instead of deriving a remedy from the code alone
2736
+ * (B5): a durability fault and a damaged registry both answer `GP_E_INTERNAL`, and the
2737
+ * sentence that is right about the second — "the projects file is the problem … repair
2738
+ * or restore the damaged entry" — is a lie about the first, whose own message says "This
2739
+ * is the write's own failure, not a verdict about the file's contents". Absent means
2740
+ * "the code's arm is the honest answer here", which is what the read-side faults below
2741
+ * rely on.
2742
+ */
2743
+ remedy;
2744
+ constructor(code, message, remedy) {
2658
2745
  super(message);
2659
2746
  this.name = "ProjectRegistryError";
2660
2747
  this.code = code;
2748
+ this.remedy = remedy;
2661
2749
  }
2662
2750
  };
2663
2751
 
@@ -3179,6 +3267,23 @@ function oversizedReplyAdvice(spec, params) {
3179
3267
  }
3180
3268
  return head + floorNote + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
3181
3269
  }
3270
+ var RING_WRITE_REASON = "it is not a cheaper re-read of the first: it appends another entry to the daemon's bounded snapshot ring, and one more entry can evict the baseline an earlier snapshot returned \u2014 the one a later restore needs";
3271
+ function narrowingKnobPhrase(spec, params) {
3272
+ const knobs = narrowingKnobsFor(spec, params);
3273
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
3274
+ const atFloor = knobs.filter(atAdvertisedFloor);
3275
+ if (shrinkable.length > 0) {
3276
+ return `the parameter that would make such a reply smaller is ${shrinkable.map(describeKnob).join(" and ")}`;
3277
+ }
3278
+ if (atFloor.length > 0) {
3279
+ return `the only parameter of that kind this tool advertises is already spent (${atFloor.map(describeKnob).join("; ")})`;
3280
+ }
3281
+ return "this tool advertises no parameter that shrinks a reply";
3282
+ }
3283
+ function payloadOverflowArm(method) {
3284
+ if (isReplayUnsafeMethod(method)) return "replay-unsafe";
3285
+ return isNarrowableByRetryMethod(method) ? "narrowable" : "ring-writing";
3286
+ }
3182
3287
  var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
3183
3288
  ...REPLAY_UNSAFE_METHOD_NAMES,
3184
3289
  ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
@@ -3380,8 +3485,9 @@ async function projectGetTool(args, context) {
3380
3485
  function mapProjectError(error) {
3381
3486
  const passed = passedThroughRegistryError(error);
3382
3487
  if (passed !== null) {
3488
+ const remedy = passed.remedy ?? projectErrorRemedy(passed.code);
3383
3489
  return {
3384
- content: [{ type: "text", text: formatToolError(passed.code, passed.message, projectErrorRemedy(passed.code)) }],
3490
+ content: [{ type: "text", text: formatToolError(passed.code, passed.message, remedy) }],
3385
3491
  isError: true
3386
3492
  };
3387
3493
  }
@@ -3400,7 +3506,8 @@ function passedThroughRegistryError(error) {
3400
3506
  return null;
3401
3507
  }
3402
3508
  const message = typeof candidate.message === "string" ? candidate.message : "";
3403
- return { code: candidate.code, message };
3509
+ const remedy = typeof candidate.remedy === "string" && candidate.remedy.trim() !== "" ? candidate.remedy : null;
3510
+ return { code: candidate.code, message, remedy };
3404
3511
  }
3405
3512
  function projectErrorRemedy(code) {
3406
3513
  if (code === GP_E_PROJECT_LIMIT) {
@@ -3657,10 +3764,12 @@ async function recentReports(args, context) {
3657
3764
  };
3658
3765
  }
3659
3766
  if (packs.length === 0) {
3767
+ const asked = skipped.length;
3768
+ const rejectClause = rejected.length === 0 ? "" : `. ${rejectedClause(rejected.length)}${renderTrailIds(rejected, format, provenanceOf)} were never sent, so the daemon has no opinion about them and nothing here reports one`;
3660
3769
  return {
3661
3770
  content: [{ type: "text", text: formatToolError(
3662
3771
  GP_E_NO_EVIDENCE,
3663
- `no pack is reachable in the daemon history for any of the ${ids.length} ${ids.length === 1 ? "operation" : "operations"} this shell asked about: each was answered GP_E_NO_EVIDENCE (${renderTrailIds(skipped, format, provenanceOf)})`,
3772
+ `no pack is reachable in the daemon history for any of the ${asked} ${asked === 1 ? "operation" : "operations"} this shell sent: each was answered GP_E_NO_EVIDENCE (${renderTrailIds(skipped, format, provenanceOf)})${rejectClause}`,
3664
3773
  "these operations are recorded in this session's trail, and the daemon holding no pack for them is a fact about its bounded history, not evidence that they did not run \u2014 this call measured nothing about a restart, so no cause is claimed here. Read one of them by id with gp_last_evidence {operationId} / gp_export_evidence {operationId}, or ask again with a smaller limit (gp_recent_reports advertises 1\u202620). Do not re-run gp_act to regenerate what the trail already holds"
3665
3774
  ) }],
3666
3775
  isError: true
@@ -4049,6 +4158,7 @@ async function handleEvidenceAggregate(url, client, res) {
4049
4158
  const renderedIds = [];
4050
4159
  const skipped = [];
4051
4160
  const notFetched = [];
4161
+ let hardFailure = null;
4052
4162
  const budget = new FanOutBudget(EVIDENCE_FANOUT_BUDGET_MS);
4053
4163
  try {
4054
4164
  for (const [index, operationId] of ids.entries()) {
@@ -4067,19 +4177,42 @@ async function handleEvidenceAggregate(url, client, res) {
4067
4177
  blocks.push(render(packForReport(pack, measuredSchemaVersion), format));
4068
4178
  renderedIds.push(operationId);
4069
4179
  } catch (error) {
4070
- failEvidenceFrameShape(res, error, operationId);
4071
- return;
4180
+ hardFailure = {
4181
+ operationId,
4182
+ kind: "frame",
4183
+ error,
4184
+ line: `${GP_E_INTERNAL}: the daemon returned a last_evidence frame this gateway cannot render (no parseable evidencePack for ${operationId}: ${boundedCause(describeCause(error))})`
4185
+ };
4186
+ notFetched.push(...ids.slice(index + 1));
4187
+ break;
4072
4188
  }
4073
4189
  } else if (settled.kind === "missing") {
4074
4190
  skipped.push(operationId);
4075
4191
  } else {
4076
- await failEngine(res, settled.error, TOOL_BY_NAME.get("gp_last_evidence"), { operationId });
4077
- return;
4192
+ hardFailure = {
4193
+ operationId,
4194
+ kind: "engine",
4195
+ error: settled.error,
4196
+ line: engineFailureLine(settled.error)
4197
+ };
4198
+ notFetched.push(...ids.slice(index + 1));
4199
+ break;
4078
4200
  }
4079
4201
  }
4080
4202
  } finally {
4081
4203
  budget.done();
4082
4204
  }
4205
+ if (blocks.length === 0 && hardFailure !== null) {
4206
+ const tail = notFetchedTail(notFetched);
4207
+ if (hardFailure.kind === "frame") {
4208
+ failEvidenceFrameShape(res, hardFailure.error, hardFailure.operationId, tail);
4209
+ return;
4210
+ }
4211
+ await failEngine(res, hardFailure.error, TOOL_BY_NAME.get("gp_last_evidence"), {
4212
+ operationId: hardFailure.operationId
4213
+ }, tail);
4214
+ return;
4215
+ }
4083
4216
  if (blocks.length === 0 && notFetched.length > 0) {
4084
4217
  fail(
4085
4218
  res,
@@ -4103,15 +4236,23 @@ async function handleEvidenceAggregate(url, client, res) {
4103
4236
  const header = format === "html" ? `<h1>GlassPane evidence reports (${blocks.length})</h1>` : `# GlassPane evidence reports (${blocks.length})`;
4104
4237
  const skipNote = makeSkipNote(skipped, format);
4105
4238
  const unfetchedNote = makeNotFetchedNote(notFetched, format);
4239
+ const failureNote = makeHardFailureNote(hardFailure, format);
4106
4240
  const separator = format === "html" ? "\n<hr>\n" : "\n\n---\n";
4107
4241
  const sections2 = [header];
4108
- for (const note of [skipNote, unfetchedNote]) {
4242
+ for (const note of [skipNote, unfetchedNote, failureNote]) {
4109
4243
  if (note !== "") {
4110
4244
  sections2.push(note);
4111
4245
  }
4112
4246
  }
4113
4247
  sections2.push(blocks.join(separator));
4114
- ok(res, { format, renderedIds, skipped, notFetched, report: sections2.join("\n") });
4248
+ ok(res, {
4249
+ format,
4250
+ renderedIds,
4251
+ skipped,
4252
+ notFetched,
4253
+ ...hardFailure === null ? {} : { failed: [hardFailure.operationId] },
4254
+ report: sections2.join("\n")
4255
+ });
4115
4256
  }
4116
4257
  async function handleToolForward(name, req, client, res) {
4117
4258
  const spec = FORWARDABLE_TOOLS.get(name);
@@ -4300,6 +4441,33 @@ function makeNotFetchedNote(notFetched, format) {
4300
4441
  const sentence = `not fetched within this request's ${EVIDENCE_FANOUT_BUDGET_MS}ms wait: ${notFetched.length} ${label} left unanswered: ${notFetched.join(", ")}. Fetch one by id with GET /v1/evidence/<operationId>, or ask again with a shorter ids= list`;
4301
4442
  return format === "html" ? `<p class="gp-not-fetched">${escapeHTML(sentence)}</p>` : `> ${sentence}`;
4302
4443
  }
4444
+ var CAUSE_MAX_CHARS = 400;
4445
+ function describeCause(error) {
4446
+ if (error instanceof Error) {
4447
+ return error.message.trim() === "" ? error.name : error.message;
4448
+ }
4449
+ return String(error);
4450
+ }
4451
+ function boundedCause(text) {
4452
+ return text.length <= CAUSE_MAX_CHARS ? text : `${text.slice(0, CAUSE_MAX_CHARS)}\u2026 [+${text.length - CAUSE_MAX_CHARS} chars not shown]`;
4453
+ }
4454
+ function engineFailureLine(error) {
4455
+ return error instanceof EngineCallError ? `${error.code}: ${boundedCause(error.message)}` : `${GP_E_INTERNAL}: ${boundedCause(describeCause(error))}`;
4456
+ }
4457
+ function notFetchedTail(notFetched) {
4458
+ if (notFetched.length === 0) {
4459
+ return "";
4460
+ }
4461
+ const label = notFetched.length === 1 ? "id" : "ids";
4462
+ return ` \u2014 and ${notFetched.length} ${label} this request never sent (${notFetched.join(", ")}), which is a stopped read, not an answer about them`;
4463
+ }
4464
+ function makeHardFailureNote(failure, format) {
4465
+ if (failure === null) {
4466
+ return "";
4467
+ }
4468
+ const sentence = `incomplete: ${failure.operationId} failed with ${failure.line} \u2014 the fan-out stopped there, so the packs above are what this request read, not the whole list`;
4469
+ return format === "html" ? `<p class="gp-incomplete">${escapeHTML(sentence)}</p>` : `> ${sentence}`;
4470
+ }
4303
4471
  function ok(res, result, attribution) {
4304
4472
  writeJson(res, 200, attribution === void 0 ? { ok: true, result } : { ok: true, result, attribution });
4305
4473
  }
@@ -4368,26 +4536,42 @@ function httpStatusForEngineCode(code) {
4368
4536
  return 422;
4369
4537
  }
4370
4538
  }
4371
- async function failEngine(res, error, spec, params) {
4539
+ async function failEngine(res, error, spec, params, tail = "") {
4372
4540
  if (error instanceof EngineCallError) {
4373
- fail(res, httpStatusForEngineCode(error.code), error.code, error.message, engineRemedy(error, spec, params));
4541
+ fail(
4542
+ res,
4543
+ httpStatusForEngineCode(error.code),
4544
+ error.code,
4545
+ `${error.message}${tail}`,
4546
+ engineRemedy(error, spec, params)
4547
+ );
4374
4548
  return;
4375
4549
  }
4376
4550
  fail(
4377
4551
  res,
4378
4552
  500,
4379
4553
  GP_E_INTERNAL,
4380
- `this gateway could not complete the request: ${String(error)}`,
4554
+ `this gateway could not complete the request: ${String(error)}${tail}`,
4381
4555
  gatewayFaultAdvice(spec)
4382
4556
  );
4383
4557
  }
4384
4558
  function engineRemedy(error, spec, params) {
4385
4559
  if (spec !== void 0 && error.code === GP_E_PAYLOAD_TOO_LARGE) {
4386
4560
  const asked = params ?? {};
4387
- return isReplayUnsafeMethod(spec.engineMethod) ? httpReplayUnsafePayloadAdvice(spec, asked) : oversizedReplyAdvice(spec, asked);
4561
+ switch (payloadOverflowArm(spec.engineMethod)) {
4562
+ case "replay-unsafe":
4563
+ return httpReplayUnsafePayloadAdvice(spec, asked);
4564
+ case "ring-writing":
4565
+ return httpRingWritingPayloadAdvice(spec, asked);
4566
+ case "narrowable":
4567
+ return oversizedReplyAdvice(spec, asked);
4568
+ }
4388
4569
  }
4389
4570
  return error.remedy;
4390
4571
  }
4572
+ function httpRingWritingPayloadAdvice(spec, params) {
4573
+ return `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); '${spec.engineMethod}' reached the daemon and was answered, and only its reply was dropped \u2014 which means the baseline it captured is already stored. Do NOT send this request again to recover that reply: ${RING_WRITE_REASON}. ${narrowingKnobPhrase(spec, params)}, and using it is the second send this answer refuses; the ids the daemon stored are only ever where a caller already has them, in the snapshot replies it has already received, and this gateway lists no ring. Read what is held, and see the attached app as it is now without changing it with POST /v1/tools/observe, POST /v1/tools/assert_element or POST /v1/tools/probe_status; an operation whose id is already held re-reads with GET /v1/evidence/<operationId>. Taking a fresh baseline is a decision, not error recovery, and the connection is intact and the daemon keeps answering other requests`;
4574
+ }
4391
4575
  function httpReplayUnsafePayloadAdvice(spec, params) {
4392
4576
  return `do not re-send this request on this answer. '${spec.engineMethod}' reached the daemon and was answered, and only its reply was dropped for exceeding one frame, so sending it again performs the change a second time rather than recovering the first answer (an act presses the same control again, a restore replays its steps again, an attach re-points the daemon at another app). Read what happened instead of acting again: GET /v1/evidence/<operationId> and POST /v1/tools/last_evidence {operationId} return the pack of an operation whose id you already hold, and POST /v1/tools/observe, POST /v1/tools/assert_element and POST /v1/tools/probe_status read the attached app as it is now without changing it \u2014 this gateway keeps no operation trail of its own, so it cannot list which operations ran. If that reading shows this request never took effect, send it again narrowed to what its own schema advertises: ${oversizedReplyAdvice(spec, params)}`;
4393
4577
  }
@@ -4398,12 +4582,12 @@ function gatewayFaultAdvice(spec) {
4398
4582
  }
4399
4583
  return `re-send this request only if it is one that changes nothing \u2014 a read; the routes this surface answers are ${TOOL_ROUTES_TEXT}. ${log}.`;
4400
4584
  }
4401
- function failEvidenceFrameShape(res, error, operationId) {
4585
+ function failEvidenceFrameShape(res, error, operationId, tail = "") {
4402
4586
  fail(
4403
4587
  res,
4404
4588
  502,
4405
4589
  GP_E_INTERNAL,
4406
- `the daemon returned a last_evidence frame that does not match the engine contract (no parseable evidencePack${operationId === void 0 ? "" : ` for ${operationId}`}: ${String(error)})`,
4590
+ `the daemon returned a last_evidence frame that does not match the engine contract (no parseable evidencePack${operationId === void 0 ? "" : ` for ${operationId}`}: ${String(error)})${tail}`,
4407
4591
  "the daemon answered this request, so the transport is up and restarting it changes nothing here; this gateway validates a pack before rendering or forwarding it, so GET /v1/evidence/<operationId> and POST /v1/tools/last_evidence {operationId} will both refuse this frame for the same reason. Read what the attached app is doing now without performing anything: POST /v1/tools/observe, POST /v1/tools/assert_element or POST /v1/tools/probe_status. Producing a fresh pack for this operation means acting again, which is a decision this read cannot make for you."
4408
4592
  );
4409
4593
  }