glasspane-mcp 1.2.0 → 1.3.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.
@@ -148,6 +148,8 @@ var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
148
148
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
149
149
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
150
150
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
151
+ var GP_E_UNKNOWN = "GP_E_UNKNOWN";
152
+ var GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
151
153
  function formatToolError(code, message, remedy) {
152
154
  return `${code}: ${message} | remedy: ${remedy}`;
153
155
  }
@@ -169,8 +171,9 @@ function defaultSocketPath() {
169
171
  return process.env[ENGINE_SOCKET_ENV] ?? path.join(os.homedir(), ".glasspane", "engine.sock");
170
172
  }
171
173
  var ENGINE_TIMEOUT_MS = 1e4;
174
+ var CALLER_VISIBLE_CEILING_MS = 5e4;
172
175
  var ENGINE_DEADLINES_MS = {
173
- hello: 15e3,
176
+ // `hello` is at the bottom of this table, where its reason is written out.
174
177
  attach: 3e4,
175
178
  act: 12e4,
176
179
  observe: 45e3,
@@ -178,14 +181,35 @@ var ENGINE_DEADLINES_MS = {
178
181
  diagnose: 3e4,
179
182
  last_evidence: 6e4,
180
183
  snapshot: 9e4,
181
- probe_status: 15e3,
182
- shutdown: 15e3
184
+ probe_status: CALLER_VISIBLE_CEILING_MS,
185
+ // R8b-高4: the same trap as `probe_status`, one layer down. `handshake()` is
186
+ // fired by the transport's `connect` event, but `ReconnectingSocketIo` is lazy:
187
+ // the frame that *causes* the connection is written first, so when a session's
188
+ // very first request is an `act`, `hello` is queued behind it. At 15 s the
189
+ // handshake expired against a healthy daemon every time, R4-06's protocol and
190
+ // version comparison never ran, and the only trace was one stderr line — a
191
+ // silent loss of the one measurement that catches "an old daemon is under this
192
+ // shell". It gets the full client wait so the comparison happens.
193
+ hello: CALLER_VISIBLE_CEILING_MS,
194
+ shutdown: 15e3,
195
+ // R8-中5: these two used to ride `ENGINE_TIMEOUT_MS * 3` by omission, which is
196
+ // a deadline nobody derived from the daemon. Both are on the wire
197
+ // (`gp_audit_ui` forwards `audit_ui`; `gp_capture_view` calls `capture_view`
198
+ // from its own executor), and `test/method-table.test.mjs` now refuses any
199
+ // sent method that is missing here.
200
+ //
201
+ // `audit_ui` is one geometry snapshot walk (`AXChannel.geometrySnapshot`,
202
+ // capped by `AXChannel.treeTimeoutSeconds` = 10 s) plus a pure-geometry
203
+ // overlap scan. It rides `observe`'s number rather than a fresh guess: same
204
+ // tree walk, without observe's pixel round trip.
205
+ audit_ui: 45e3,
206
+ // `capture_view` is attach + `SCKCapturer.captureWindow` + PNG encode. The
207
+ // capture tries up to two candidate surfaces at `captureTimeoutSeconds` = 5 s
208
+ // each (R6-11 made the second candidate conditional, so two attempts is the
209
+ // real worst case), then encodes inside the frame budget.
210
+ capture_view: 3e4
183
211
  };
184
212
  var RESTORE_BASE_DEADLINE_MS = 3e4;
185
- var CALLER_VISIBLE_CEILING_MS = 5e4;
186
- function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS) {
187
- return Math.min(engineDeadlineMs(method, params), ceilingMs);
188
- }
189
213
  var REPLAY_UNSAFE_METHODS = /* @__PURE__ */ new Set(["act", "restore"]);
190
214
  function engineDeadlineMs(method, params) {
191
215
  if (method === "restore") {
@@ -196,6 +220,9 @@ function engineDeadlineMs(method, params) {
196
220
  const known = ENGINE_DEADLINES_MS[method];
197
221
  return known ?? ENGINE_TIMEOUT_MS * 3;
198
222
  }
223
+ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS) {
224
+ return Math.min(engineDeadlineMs(method, params), ceilingMs);
225
+ }
199
226
  var ENGINE_PROTOCOL_VERSION = "0";
200
227
  var EngineCallError = class extends Error {
201
228
  code;
@@ -212,21 +239,50 @@ var EngineCallError = class extends Error {
212
239
  return { code: this.code, message: this.message, remedy: this.remedy };
213
240
  }
214
241
  };
215
- function slowEngineRemedy(method) {
216
- const poll = "confirm the daemon is alive with a cheap call instead \u2014 gp_probe_status goes out immediately while this request is still outstanding (the shell no longer queues one MCP request behind another) and is answered as soon as the daemon is free of it";
242
+ var LIVENESS_PROBE_OUTCOMES = [
243
+ "answered",
244
+ "engine-error",
245
+ "timed-out",
246
+ "unreachable"
247
+ ];
248
+ function livenessProbeDecision(outcome) {
249
+ switch (outcome) {
250
+ case "answered":
251
+ return "answered at all (a result or an engine error frame) => the daemon is alive and has worked its way past the request in front of yours: keep waiting, the outstanding call's reply is still attributed and recorded";
252
+ case "engine-error":
253
+ return "answered with a named engine error code => the daemon is alive, so this answer never means \"restart the daemon because it is wedged\"; follow that code's own remedy instead, and note that some codes genuinely do name a restart (`GP_E_AX_UNAVAILABLE` does, because a wedged Accessibility API needs the service recycled) \u2014 that is the daemon's decision to make, not this sentence's";
254
+ case "timed-out":
255
+ return `not answered within its own window => NOT evidence that the daemon is down: it is single-connection and still busy with the earlier request (this is ${GP_E_ENGINE_TIMEOUT}, and the request stays registered so the real reply is attributed when it lands). Keep waiting; do not run the restore command on this answer alone, because restarting cancels and rolls back an action that is mid-flight on the user's screen`;
256
+ case "unreachable":
257
+ return `${GP_E_ENGINE_UNREACHABLE} (nothing is listening, or the transport died) => this is the one answer that justifies touching the daemon's lifecycle: ${daemonUnreachableRemedy()}`;
258
+ }
259
+ }
260
+ function livenessProbeGuide() {
261
+ return LIVENESS_PROBE_OUTCOMES.map((outcome) => `${outcome} \u2014 ${livenessProbeDecision(outcome)}`).join("; ");
262
+ }
263
+ function lateReplyRoute(surface) {
264
+ return surface === "mcp" ? "the original reply, when it lands, is written to this server's log (stderr) *and* its operationId is recorded in this session's trail, so a later gp_recent_reports lists the operation that ran instead of telling you to run it again \u2014 **while this server still tracks the request**: the window is bounded (" + LATE_REPLY_RETENTION + " timed-out requests, and an eviction is logged), a re-attach in between supersedes it (also logged, with the operationIds it dropped), and if this process has exited nothing will be recorded at all" : "the original reply, when it lands, is written to this gateway's stderr with its body, so the operationId it carries can be read off that line and fetched with GET /v1/evidence/<operationId> \u2014 the tracking window is bounded (" + LATE_REPLY_RETENTION + " timed-out requests, and an eviction is logged), and this gateway keeps no operation trail of its own";
265
+ }
266
+ function slowEngineRemedy(method, surface = "mcp") {
267
+ const poll = "if your client can send a second request while this one is still outstanding, confirm the daemon is alive with a cheap call: the shell does not queue one MCP request behind another, so gp_probe_status goes out at once and is answered as soon as the daemon is free of the request in front of it. Read its answer as: " + livenessProbeGuide();
268
+ const restarted = livenessProbeDecision("unreachable");
217
269
  if (REPLAY_UNSAFE_METHODS.has(method)) {
218
- return `${poll}. The daemon serves one request at a time and is still working on this one, so wait for it; do NOT re-issue ${method}, because the original action can still take effect on the user's screen \u2014 the original reply is written to this server's log (stderr) when it lands, which is where the answer is. Restart only if gp_probe_status times out too \u2014 then ${daemonUnreachableRemedy()}`;
270
+ return `${poll}. The daemon serves one request at a time and is still working on this one, so wait for it; do NOT re-issue ${method}, because the original action can still take effect on the user's screen \u2014 ${lateReplyRoute(surface)}. Only ${restarted}`;
219
271
  }
220
- return `${poll}. The daemon serves one request at a time and is still working on this one, so wait, then retry this read narrower (smaller maxDepth / a tighter selector); if gp_probe_status times out as well, ${daemonUnreachableRemedy()}`;
272
+ if (method === "probe_status") {
273
+ return `the liveness probe itself has not been answered, and it is given the longest wait this shell allows any request (${CALLER_VISIBLE_CEILING_MS}ms), so this particular answer carries no information about whether the daemon is alive: the daemon is single-connection and is still working on the request in front of this probe. ${livenessProbeDecision("timed-out")}. Keep waiting instead \u2014 the outstanding call's reply is attributed when it lands, and ${lateReplyRoute(surface)}. If the daemon's socket really is gone, the next call answers with ${GP_E_ENGINE_UNREACHABLE}, and that remedy names the restore command.`;
274
+ }
275
+ return `${poll}. The daemon serves one request at a time and is still working on this one, so wait, then retry this read narrower (smaller maxDepth / a tighter selector) \u2014 that is safe for a read; a restart is authorised only by ${restarted}`;
221
276
  }
222
277
  var LATE_REPLY_RETENTION = 16;
223
278
  var LOG_BODY_CHARS = 64 * 1024;
224
279
  var EngineJsonRpcClient = class {
225
- constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS) {
280
+ constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp") {
226
281
  this.io = io;
227
282
  this.timeoutMs = timeoutMs;
228
283
  this.identity = identity;
229
284
  this.callerCeilingMs = callerCeilingMs;
285
+ this.surface = surface;
230
286
  this.io.onMessage((line) => this.handleMessage(line));
231
287
  this.io.onError((error) => {
232
288
  if (error instanceof OversizeFrameError) {
@@ -250,10 +306,12 @@ var EngineJsonRpcClient = class {
250
306
  timeoutMs;
251
307
  identity;
252
308
  callerCeilingMs;
309
+ surface;
253
310
  nextId = 0;
254
311
  closed = false;
255
312
  pending = /* @__PURE__ */ new Map();
256
313
  noteHandler = null;
314
+ lateReplyHandler = null;
257
315
  /**
258
316
  * Sink for facts that cannot be attached to any request of the caller: a
259
317
  * late reply, a frame with an id this client never issued, a daemon that
@@ -263,8 +321,26 @@ var EngineJsonRpcClient = class {
263
321
  onEngineNote(handler) {
264
322
  this.noteHandler = handler;
265
323
  }
266
- /** Send a request and await its matching response frame. */
267
- call(method, params) {
324
+ /**
325
+ * Sink for the *body* of a reply that lands after its caller was already
326
+ * answered (R8-高3). Logging it is not enough: the operationId in that frame
327
+ * is the only thing that puts the operation into this session's evidence
328
+ * trail, and without it `gp_recent_reports` answers
329
+ * `GP_E_NO_EVIDENCE ... run gp_act first` for an act that genuinely ran and
330
+ * genuinely mutated the user's screen — an agent reading that answer clicks
331
+ * again. The entry the reply is attributed to is only kept for
332
+ * `LATE_REPLY_RETENTION` requests, so this sink sees the tracked window and
333
+ * the eviction note says when something fell outside it.
334
+ */
335
+ onLateReply(handler) {
336
+ this.lateReplyHandler = handler;
337
+ }
338
+ /**
339
+ * Send a request and await its matching response frame. `correlation` is
340
+ * echoed to {@link onLateReply} if this request is answered after its caller
341
+ * has already been given a timeout; it is otherwise unused by this client.
342
+ */
343
+ call(method, params, correlation) {
268
344
  const id = this.nextId++;
269
345
  const frame = { id, method, ...params === void 0 ? {} : { params } };
270
346
  const methodDeadlineMs = this.timeoutMs ?? engineDeadlineMs(method, params);
@@ -273,6 +349,8 @@ var EngineJsonRpcClient = class {
273
349
  const entry = {
274
350
  id,
275
351
  method,
352
+ correlation,
353
+ params,
276
354
  deadlineMs,
277
355
  methodDeadlineMs,
278
356
  timer: null,
@@ -316,7 +394,7 @@ var EngineJsonRpcClient = class {
316
394
  this.reject(entry, new EngineCallError(
317
395
  GP_E_ENGINE_TIMEOUT,
318
396
  `engine is still working on '${entry.method}': no reply after ${entry.deadlineMs}ms. The request stays registered \u2014 a late reply is written to the server log \u2014 so this is a busy or slow daemon, not an unreachable one.` + capped,
319
- slowEngineRemedy(entry.method)
397
+ slowEngineRemedy(entry.method, this.surface)
320
398
  ));
321
399
  this.retainForLateReply();
322
400
  }
@@ -403,12 +481,12 @@ var EngineJsonRpcClient = class {
403
481
  this.reject(entry, attribute(new EngineCallError(
404
482
  GP_E_PAYLOAD_TOO_LARGE,
405
483
  `the engine's reply to '${entry.method}' exceeded the ${MAX_FRAME_BYTES}-byte frame cap (${error.bytes} bytes) and was dropped; the connection is intact and other requests are unaffected`,
406
- "narrow this request: retry with a smaller maxDepth or a more specific selector (the daemon keeps answering other requests)"
484
+ "narrow this request or ask for less of it; the tool's own schema names the parameters that can shrink a reply, and this shell rewrites this line into that advice when it can (the transport layer deliberately does not guess them)"
407
485
  ), entry, inferred));
408
486
  }
409
487
  engineError(body) {
410
488
  return new EngineCallError(
411
- String(body.code ?? "GP_E_UNKNOWN"),
489
+ String(body.code ?? GP_E_UNKNOWN),
412
490
  String(body.message ?? "the engine sent an error frame without a message"),
413
491
  typeof body.remedy === "string" && body.remedy !== "" ? body.remedy : "the engine sent no remedy for this error code; check the daemon log (stderr) and retry"
414
492
  );
@@ -427,6 +505,18 @@ var EngineJsonRpcClient = class {
427
505
  this.report(
428
506
  `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: ${body}`
429
507
  );
508
+ if (frame.error || frame.result === void 0) {
509
+ return;
510
+ }
511
+ const handler = this.lateReplyHandler;
512
+ if (handler === null) {
513
+ return;
514
+ }
515
+ try {
516
+ handler({ method: entry.method, result: frame.result, correlation: entry.correlation });
517
+ } catch (error) {
518
+ this.report(`the late-reply sink failed for '${entry.method}' (id ${entry.id}): ${String(error)}`);
519
+ }
430
520
  }
431
521
  resolve(entry, value) {
432
522
  this.complete(entry);
@@ -592,7 +682,8 @@ function unixSocketEngineClient(socketPath, options = {}) {
592
682
  new ReconnectingSocketIo(socketPath, open),
593
683
  void 0,
594
684
  options.identity ?? null,
595
- options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS
685
+ options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS,
686
+ options.surface ?? "mcp"
596
687
  );
597
688
  }
598
689
 
@@ -1120,8 +1211,21 @@ var PROTECTED_SUBTREES = [
1120
1211
  "/private/tmp",
1121
1212
  "/private/var/tmp"
1122
1213
  ];
1214
+ function systemHome() {
1215
+ try {
1216
+ return os2.userInfo().homedir;
1217
+ } catch (error) {
1218
+ throw new ProjectRegistryError(
1219
+ // Not GP_E_INTERNAL: `tools.ts` maps that code to "the projects file is
1220
+ // damaged, go read it", and for this failure there is no damaged file —
1221
+ // the process has no user record to derive a path from (R8b).
1222
+ GP_E_NO_USER_RECORD,
1223
+ `cannot read the user record this shell's home directory comes from (${describeError(error)}). The background service derives its state root the same way (NSHomeDirectory(), not $HOME), so there is no value here to fall back to: guessing from $HOME would name a projects.json the daemon does not load and would let the home-directory protection guard the wrong tree. Run this shell as a user that has an entry in the password database.`
1224
+ );
1225
+ }
1226
+ }
1123
1227
  function daemonProjectsPath() {
1124
- return path2.join(os2.homedir(), ".glasspane", "projects.json");
1228
+ return path2.join(systemHome(), ".glasspane", "projects.json");
1125
1229
  }
1126
1230
  var FIRMLINK_PREFIX = "/System/Volumes/Data";
1127
1231
  var FORCE_OVERWRITE_ENV = "GLASSPANE_PROJECTS_FORCE_OVERWRITE";
@@ -1415,7 +1519,7 @@ function badPath(field, value, problem) {
1415
1519
  }
1416
1520
  var PATH_SHAPE_HINT = `An acceptable value is a project-owned directory at least two levels deep, e.g. "/Users/you/work/notes-app/.glasspane/evidence": absolute, with no "." or ".." components, not world-writable, with its nearest existing directory owned by the user running this shell (never a shared tree such as /Users/Shared, another user's home, /private/tmp, or a top-level directory of the boot volume), and not the filesystem root, a mounted volume root, your home directory, anything under ~/Library, or a system-owned tree (${PROTECTED_SUBTREES.join(", ")}) \u2014 the daemon writes evidence into this directory and deletes expired entries from it`;
1417
1521
  function refuseCandidate(target) {
1418
- const home = stripFirmlink(path2.posix.normalize(os2.homedir()));
1522
+ const home = stripFirmlink(path2.posix.normalize(systemHome()));
1419
1523
  if (target === home) return named("the current user's home directory");
1420
1524
  if (isInside(`${home}/Library`, target)) return named("inside the current user's Library folder");
1421
1525
  if (isProtectedRoot(target)) return named("a protected system root");
@@ -1547,8 +1651,16 @@ var AttachArgs = z5.strictObject({
1547
1651
  bundleId: OptionalBundleIdSchema,
1548
1652
  pid: OptionalPidSchema,
1549
1653
  projectId: OptionalProjectIdSchema
1550
- }).refine((v) => v.bundleId !== void 0 || v.pid !== void 0, {
1551
- message: "attach requires either bundleId or pid"
1654
+ }).refine((v) => v.bundleId === void 0 !== (v.pid === void 0), {
1655
+ // Exclusive, not merely "at least one" (R8-中6). The advertised JSON Schema
1656
+ // below already says `oneOf` (exactly one), and the daemon resolves both
1657
+ // fields by *preferring pid and ignoring bundleId*
1658
+ // (`AXChannel.resolveRunningApplication`: `if let pid { … } if let bundleId { … }`).
1659
+ // So the loose zod rule let an agent name two different apps and get the one
1660
+ // it did not mean, attached, and acting on the user's screen — with the reply
1661
+ // naming only one of them. Refusing is the only answer that cannot mislead.
1662
+ message: "attach takes exactly one of bundleId or pid, not both and not neither",
1663
+ path: ["bundleId"]
1552
1664
  });
1553
1665
  var ObserveArgs = z5.strictObject({
1554
1666
  maxDepth: OptionalDepthSchema,
@@ -1896,6 +2008,47 @@ var TOOL_SPECS = [
1896
2008
  var TOOL_BY_NAME = new Map(
1897
2009
  TOOL_SPECS.map((spec) => [spec.name, spec])
1898
2010
  );
2011
+ function narrowingKnobsFor(spec, params) {
2012
+ const properties = spec.inputSchema.properties ?? {};
2013
+ const knobs = [];
2014
+ for (const [name, raw] of Object.entries(properties)) {
2015
+ const schema = raw;
2016
+ const sentValue = params[name];
2017
+ const sent = typeof sentValue === "number" ? sentValue : void 0;
2018
+ if (name === "maxDepth") {
2019
+ knobs.push({ name, kind: "depth", minimum: schema.minimum, maximum: schema.maximum, sent });
2020
+ } else if (name === "scale") {
2021
+ knobs.push({ name, kind: "scale", minimum: schema.minimum, maximum: schema.maximum, sent });
2022
+ } else if (name === "limit") {
2023
+ knobs.push({ name, kind: "limit", minimum: schema.minimum, maximum: schema.maximum, sent });
2024
+ } else if (name === "selector" || name === "role" || name === "title") {
2025
+ knobs.push({ name, kind: "selector", sent: void 0 });
2026
+ }
2027
+ }
2028
+ return knobs;
2029
+ }
2030
+ function describeKnob(knob) {
2031
+ const bounds = knob.minimum !== void 0 && knob.maximum !== void 0 ? ` (this tool advertises ${knob.minimum}\u2026${knob.maximum})` : knob.maximum !== void 0 ? ` (this tool advertises at most ${knob.maximum})` : knob.minimum !== void 0 ? ` (this tool advertises at least ${knob.minimum})` : "";
2032
+ switch (knob.kind) {
2033
+ case "depth":
2034
+ return `a smaller maxDepth${bounds}`;
2035
+ case "limit":
2036
+ return `a smaller limit${bounds}`;
2037
+ case "selector":
2038
+ return knob.name === "selector" ? "a more specific selector (aim at one element)" : `a tighter ${knob.name} filter`;
2039
+ case "scale":
2040
+ return knob.sent !== void 0 && knob.minimum !== void 0 && knob.sent <= knob.minimum ? `scale is already at this tool's floor (${knob.minimum}), so the image cannot be shrunk further here` : `a smaller scale${bounds}${knob.sent !== void 0 ? `, currently ${knob.sent}` : ""}`;
2041
+ }
2042
+ }
2043
+ function oversizedReplyAdvice(spec, params) {
2044
+ const knobs = narrowingKnobsFor(spec, params);
2045
+ const shrinkable = knobs.filter((knob) => !(knob.kind === "scale" && knob.sent !== void 0 && knob.minimum !== void 0 && knob.sent <= knob.minimum));
2046
+ const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
2047
+ if (shrinkable.length === 0) {
2048
+ return knobs.length > 0 ? head + `${knobs.map(describeKnob).join("; ")} \u2014 nothing left to narrow on this request: ask a smaller question (one element, one region) rather than retrying it unchanged; the connection is intact and the daemon keeps answering other requests` : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2049
+ }
2050
+ return head + `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`;
2051
+ }
1899
2052
  async function readTrail(session, turn, limit) {
1900
2053
  await turn.acquire();
1901
2054
  try {
@@ -1936,7 +2089,7 @@ function packForReport(pack, measuredSchemaVersion) {
1936
2089
  }
1937
2090
  async function captureView(args, context) {
1938
2091
  try {
1939
- const raw = await context.engine.call("capture_view", args);
2092
+ const raw = await context.engine.call("capture_view", args, context.session.generation);
1940
2093
  const body = raw ?? {};
1941
2094
  const png = body.pngBase64;
1942
2095
  if (typeof png !== "string" || png.length === 0) {
@@ -2046,9 +2199,12 @@ async function projectGetTool(args) {
2046
2199
  if (entry === void 0) {
2047
2200
  return {
2048
2201
  content: [{ type: "text", text: formatToolError(
2049
- "GP_E_NOT_FOUND",
2202
+ // Both the code and the remedy come from the one place they are
2203
+ // spelled: this used to retype the literal and the sentence, so a
2204
+ // change to `projectErrorRemedy` left this reply behind (R8-低).
2205
+ GP_E_NOT_FOUND,
2050
2206
  `unknown project ${argv.projectId}`,
2051
- "check the projectId; use gp_project_list to view available projects"
2207
+ projectErrorRemedy(GP_E_NOT_FOUND)
2052
2208
  ) }],
2053
2209
  isError: true
2054
2210
  };
@@ -2085,6 +2241,9 @@ function projectErrorRemedy(code) {
2085
2241
  if (code === GP_E_NOT_FOUND) {
2086
2242
  return "check the projectId; use gp_project_list to view available projects";
2087
2243
  }
2244
+ if (code === GP_E_NO_USER_RECORD) {
2245
+ return "this process has no entry in the password database, so neither it nor the daemon can know which user's state directory is meant; run the shell as a user that has one (`id -u` / `dscl . -read /Users/<name> NFSHomeDirectory`). Nothing in ~/.glasspane needs repairing for this";
2246
+ }
2088
2247
  if (code === GP_E_INTERNAL) {
2089
2248
  return `the projects file is the problem, not your arguments: the message above names its path, so read it with \`python3 -m json.tool <that path>\`, repair or restore the damaged entry, and retry \u2014 or set ${FORCE_OVERWRITE_ENV}=1 and call gp_project_set to rebuild the registry from scratch (the unloadable file is moved aside as <path>.unreadable-<id>, never deleted, and every entry still in it is lost). Do not retry the same read-only call unchanged: it will keep failing.`;
2090
2249
  }
@@ -2094,7 +2253,11 @@ async function exportEvidence(args, context) {
2094
2253
  const argv = args;
2095
2254
  const render2 = argv.format === "html" ? renderHTML : renderMarkdown;
2096
2255
  try {
2097
- const raw = await context.engine.call("last_evidence", { operationId: argv.operationId });
2256
+ const raw = await context.engine.call(
2257
+ "last_evidence",
2258
+ { operationId: argv.operationId },
2259
+ context.session.generation
2260
+ );
2098
2261
  const { pack, measuredSchemaVersion } = parseEvidenceFrame(raw);
2099
2262
  await context.turn.acquire();
2100
2263
  try {
@@ -2124,7 +2287,11 @@ async function recentReports(args, context) {
2124
2287
  const skipped = [];
2125
2288
  for (const id of ids) {
2126
2289
  try {
2127
- const raw = await context.engine.call("last_evidence", { operationId: id });
2290
+ const raw = await context.engine.call(
2291
+ "last_evidence",
2292
+ { operationId: id },
2293
+ context.session.generation
2294
+ );
2128
2295
  const parsed = parseEvidenceFrame(raw);
2129
2296
  packs.push({ id, pack: packForReport(parsed.pack, parsed.measuredSchemaVersion) });
2130
2297
  } catch (error) {
@@ -2205,7 +2372,18 @@ function createHttpGateway(options) {
2205
2372
  `createHttpGateway requires a non-empty token (read ${GLASSPANE_HTTP_TOKEN_ENV}); refusing to start with an empty or defaulted token`
2206
2373
  );
2207
2374
  }
2208
- const client = options.connectController !== void 0 ? options.connectController(options.socketPath) : unixSocketEngineClient(options.socketPath);
2375
+ const report = options.report ?? ((note) => {
2376
+ try {
2377
+ process.stderr.write(`[glasspane-http] ${note}
2378
+ `);
2379
+ } catch {
2380
+ }
2381
+ });
2382
+ const client = options.connectController !== void 0 ? options.connectController(options.socketPath) : unixSocketEngineClient(options.socketPath, { surface: "http" });
2383
+ client.onEngineNote?.((note) => report(`engine: ${note}`));
2384
+ client.onLateReply?.(({ method, result }) => {
2385
+ report(`late engine reply to '${method}': ${JSON.stringify(result) ?? String(result)}`);
2386
+ });
2209
2387
  const expectedToken = Buffer.from(options.token, "utf8");
2210
2388
  const server = http.createServer((req, res) => {
2211
2389
  handleHttp(req, res, client, expectedToken).catch((error) => {
@@ -2370,7 +2548,7 @@ async function handleEvidenceAggregate(url, client, res) {
2370
2548
  if (error instanceof EngineCallError && error.code === GP_E_NO_EVIDENCE) {
2371
2549
  skipped.push(operationId);
2372
2550
  } else {
2373
- await failEngine(res, error);
2551
+ await failEngine(res, error, TOOL_BY_NAME.get("gp_last_evidence"), { operationId });
2374
2552
  return;
2375
2553
  }
2376
2554
  }
@@ -2470,7 +2648,7 @@ async function handleToolForward(name, req, client, res) {
2470
2648
  }
2471
2649
  ok(res, result);
2472
2650
  } catch (error) {
2473
- await failEngine(res, error);
2651
+ await failEngine(res, error, spec, checked.value);
2474
2652
  }
2475
2653
  }
2476
2654
  function handleEvidenceStats(res) {
@@ -2591,10 +2769,11 @@ function NotFound(res, pathname) {
2591
2769
  "GET /v1/hello, GET /v1/evidence/:operationId, GET /v1/evidence?ids=a,b&format=html, POST /v1/tools/:name"
2592
2770
  );
2593
2771
  }
2594
- async function failEngine(res, error) {
2772
+ async function failEngine(res, error, spec, params) {
2595
2773
  if (error instanceof EngineCallError) {
2596
2774
  const status = error.code === GP_E_NO_EVIDENCE ? 404 : 502;
2597
- fail(res, status, error.code, error.message, error.remedy);
2775
+ const remedy = spec !== void 0 && error.code === GP_E_PAYLOAD_TOO_LARGE ? oversizedReplyAdvice(spec, params ?? {}) : error.remedy;
2776
+ fail(res, status, error.code, error.message, remedy);
2598
2777
  return;
2599
2778
  }
2600
2779
  fail(
@@ -19,6 +19,20 @@ export interface EngineClientLike {
19
19
  call(method: string, params?: Record<string, unknown>): Promise<unknown>;
20
20
  /** Present on the real client; optional so a fake may omit it. */
21
21
  close?(): void;
22
+ /**
23
+ * Sinks for what this gateway cannot answer with: a fact attached to no
24
+ * request (an id-less frame, a handshake that failed) and the body of a reply
25
+ * that landed after its caller was already answered. Both are the daemon's
26
+ * real behaviour, and with no sink they left no trace at all — while the
27
+ * remedy this gateway hands out tells the caller to go read stderr
28
+ * (R8-高3). Optional so a fake may omit it; the gateway refuses to *claim* a
29
+ * log line it did not register for.
30
+ */
31
+ onEngineNote?(handler: (note: string) => void): void;
32
+ onLateReply?(handler: (reply: {
33
+ method: string;
34
+ result: unknown;
35
+ }) => void): void;
22
36
  }
23
37
  export interface HttpGatewayOptions {
24
38
  /** Daemon socket path handed to the engine client (the default connector). */
@@ -29,6 +43,13 @@ export interface HttpGatewayOptions {
29
43
  host?: string;
30
44
  /** Injectable client factory so tests run without a real socket. */
31
45
  connectController?: (socketPath: string) => EngineClientLike;
46
+ /**
47
+ * Where to write the facts that have no HTTP response: engine notes and late
48
+ * replies. Defaults to stderr with this process's own prefix, because the
49
+ * remedy text this gateway hands out tells a caller to read *this gateway's
50
+ * stderr* — a promise that has to have a writer behind it (R8-高3).
51
+ */
52
+ report?: (note: string) => void;
32
53
  }
33
54
  export interface HttpGateway {
34
55
  server: Server;