glasspane-mcp 1.10.0 → 1.12.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.10.0";
29
+ readonly version: "1.12.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -533,8 +533,9 @@ var EngineJsonRpcClient = class {
533
533
  return;
534
534
  }
535
535
  if (frame.error) {
536
- this.pending.delete(target.id);
537
536
  this.reject(target, attribute(this.engineError(frame.error), target));
537
+ target.arrivalOrderConsumed = true;
538
+ this.retainForLateReply();
538
539
  return;
539
540
  }
540
541
  this.report(`engine sent an id-less frame with a result while '${target.method}' (id ${target.id}) is still waiting for its answer; an id-less answer from this daemon is an error frame, so this one was not delivered to any caller: ${truncate(line)}`);
@@ -633,8 +634,9 @@ var EngineJsonRpcClient = class {
633
634
  );
634
635
  return;
635
636
  }
637
+ const bodyBytes = Buffer.byteLength(jsonOrString(frame.result), "utf8");
636
638
  this.report(
637
- `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: ${truncate(jsonOrString(frame.result))}${byOrder}`
639
+ `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: the engine did answer, with a ${bodyBytes}-byte body that is raw engine content (an accessibility tree whose title/value fields are on-screen data), so its text is not written to this log${byOrder}`
638
640
  );
639
641
  if (frame.result === void 0) {
640
642
  return;
@@ -817,7 +819,7 @@ var ReconnectingSocketIo = class {
817
819
  const io = new StreamLineIo(socket, socket);
818
820
  io.onMessage((line) => this.messageHandler?.(line));
819
821
  io.onError((error) => {
820
- if (this.inner === io) {
822
+ if (!(error instanceof OversizeFrameError) && this.inner === io) {
821
823
  this.inner = null;
822
824
  io.close();
823
825
  }
@@ -1885,8 +1887,8 @@ var ProjectRegistryError = class extends Error {
1885
1887
  var PID_INT32_MAX = 2147483647;
1886
1888
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
1887
1889
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
1888
- var OptionalRoleSchema = z5.string().max(128).optional();
1889
- var OptionalBundleIdSchema = z5.string().max(256).optional();
1890
+ var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
1891
+ var OptionalBundleIdSchema = z5.string().max(512).optional();
1890
1892
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1891
1893
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
1892
1894
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
@@ -2002,7 +2004,7 @@ var TOOL_SPECS = [
2002
2004
  type: "object",
2003
2005
  additionalProperties: false,
2004
2006
  properties: {
2005
- bundleId: { type: "string", maxLength: 256 },
2007
+ bundleId: { type: "string", maxLength: 512 },
2006
2008
  pid: { type: "integer", minimum: 1, maximum: PID_INT32_MAX },
2007
2009
  projectId: { type: "string", pattern: "^prj_[0-9A-HJKMNP-TV-Z]{26}$" }
2008
2010
  },
@@ -2022,7 +2024,7 @@ var TOOL_SPECS = [
2022
2024
  additionalProperties: false,
2023
2025
  properties: {
2024
2026
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2025
- role: { type: "string", maxLength: 128 }
2027
+ role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2026
2028
  }
2027
2029
  },
2028
2030
  validate: zodBridge(ObserveArgs)
@@ -2370,6 +2372,9 @@ var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
2370
2372
  ...REPLAY_UNSAFE_METHOD_NAMES,
2371
2373
  ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
2372
2374
  ]);
2375
+ function isNarrowableByRetryMethod(method) {
2376
+ return !NOT_NARROWABLE_BY_RETRY.has(method);
2377
+ }
2373
2378
  async function readTrail(session, turn, limit) {
2374
2379
  await turn.acquire();
2375
2380
  try {
@@ -2378,6 +2383,17 @@ async function readTrail(session, turn, limit) {
2378
2383
  turn.release();
2379
2384
  }
2380
2385
  }
2386
+ function shellFaultRemedy(engineMethod) {
2387
+ const readRoute = "read what happened instead of performing it again: gp_recent_reports lists the operations this session recorded, gp_last_evidence {operationId} returns one in full, and gp_probe_status reads the daemon's own self-report on this same connection \u2014 all three are requests this shell does not treat as unsafe to repeat";
2388
+ const reportRoute = "this fault was raised inside the MCP shell, so it is in no daemon log: this process's stderr belongs to whoever started this server, and that line is the only text naming the failure, so hand it to them verbatim. A daemon restart is not authorised by an answer the daemon never made";
2389
+ if (isReplayUnsafeMethod(engineMethod)) {
2390
+ return `this is a defect in this shell, not an answer from the engine, so it measures nothing about whether '${engineMethod}' reached the daemon or the user's screen. Do NOT re-issue ${engineMethod}: a re-issue can perform the same action a second time on the user's screen (and for attach, re-point the daemon and discard the trail an outstanding request is being recorded in), and this failure does not establish that the first copy did nothing. ${readRoute}. ${reportRoute}`;
2391
+ }
2392
+ if (!isNarrowableByRetryMethod(engineMethod)) {
2393
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is a method whose re-issue writes daemon state \u2014 a fresh request appends to the bounded snapshot ring and can evict a baseline this caller already received \u2014 so treat it as a question to read around rather than one to send again: gp_snapshot lists stored ids, and ${readRoute}. ${reportRoute}`;
2394
+ }
2395
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is not one of the methods this shell classifies as unsafe to repeat (the no-replay set is act/restore/attach, plus snapshot, whose re-issue writes daemon state), so sending this tool again is a real option rather than a side effect: do that once. If the fault repeats, ${readRoute}. ${reportRoute}`;
2396
+ }
2381
2397
  var EvidenceFrameShapeError = class extends Error {
2382
2398
  constructor(detail) {
2383
2399
  super(`engine returned a last_evidence frame without an evidencePack object: ${detail}`);
@@ -2433,7 +2449,17 @@ async function captureView(args, context) {
2433
2449
  isError: true
2434
2450
  };
2435
2451
  }
2436
- const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2452
+ if (typeof body.mimeType !== "string" || body.mimeType.length === 0) {
2453
+ return {
2454
+ content: [{ type: "text", text: formatToolError(
2455
+ GP_E_INTERNAL,
2456
+ `capture_view answered without a mimeType (got ${JSON.stringify(body.mimeType ?? null)})`,
2457
+ "do not read this image as any particular format: the engine did not name one, and this shell will not fill the field in. This is a read-only capture, so sending it again performs nothing \u2014 if it repeats, report the frame shape, which is a protocol disagreement between two builds rather than a dead daemon"
2458
+ ) }],
2459
+ isError: true
2460
+ };
2461
+ }
2462
+ const mimeType = body.mimeType;
2437
2463
  const summary = canonicalJson2({
2438
2464
  mimeType,
2439
2465
  byteCount: body.byteCount,
@@ -2465,7 +2491,7 @@ async function captureView(args, context) {
2465
2491
  content: [{ type: "text", text: formatToolError(
2466
2492
  GP_E_INTERNAL,
2467
2493
  `internal shell error: ${String(error)}`,
2468
- "see the MCP server logs and retry"
2494
+ shellFaultRemedy("capture_view")
2469
2495
  ) }],
2470
2496
  isError: true
2471
2497
  };
@@ -2566,7 +2592,7 @@ function projectErrorRemedy(code) {
2566
2592
  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";
2567
2593
  }
2568
2594
  if (code === GP_E_INTERNAL) {
2569
- 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.`;
2595
+ return `the projects file is the problem, not your arguments: the message above names its path, so read it with \`/usr/bin/plutil -p <that path>\` or \`/bin/cat <that path>\` \u2014 both absolute, so they run from whatever directory the reader is in and need nothing that may be off PATH \u2014 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.`;
2570
2596
  }
2571
2597
  return "check the tool's input schema and retry";
2572
2598
  }
@@ -2641,7 +2667,7 @@ function renderTrailIds(ids, format) {
2641
2667
  }
2642
2668
  function describeFetchFailure(error) {
2643
2669
  if (error instanceof EngineCallError) {
2644
- return `${error.code}: ${error.message}`;
2670
+ return formatToolErrorShape(error.toBody());
2645
2671
  }
2646
2672
  const mapped = mapEvidenceReadError(error);
2647
2673
  if (mapped !== void 0) {
@@ -2762,8 +2788,8 @@ async function recentReports(args, context) {
2762
2788
  return {
2763
2789
  content: [{ type: "text", text: formatToolError(
2764
2790
  GP_E_NO_EVIDENCE,
2765
- "no recent evidence is reachable in the daemon history",
2766
- "the engine may have restarted; re-run gp_act / gp_assert_element to regenerate evidence"
2791
+ `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)})`,
2792
+ "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"
2767
2793
  ) }],
2768
2794
  isError: true
2769
2795
  };
@@ -2786,7 +2812,7 @@ async function recentReports(args, context) {
2786
2812
  sections2.push(body);
2787
2813
  return { content: [{ type: "text", text: sections2.join("\n") }], isError: false };
2788
2814
  }
2789
- function mapAuditError(error) {
2815
+ function mapAuditError(error, engineMethod = "last_evidence") {
2790
2816
  if (error instanceof EngineCallError) {
2791
2817
  return {
2792
2818
  content: [{ type: "text", text: formatToolErrorShape(error.toBody()) }],
@@ -2801,7 +2827,7 @@ function mapAuditError(error) {
2801
2827
  content: [{ type: "text", text: formatToolError(
2802
2828
  GP_E_INTERNAL,
2803
2829
  `internal shell error: ${String(error)}`,
2804
- "see the MCP server logs and retry"
2830
+ shellFaultRemedy(engineMethod)
2805
2831
  ) }],
2806
2832
  isError: true
2807
2833
  };
@@ -529,8 +529,9 @@ var EngineJsonRpcClient = class {
529
529
  return;
530
530
  }
531
531
  if (frame.error) {
532
- this.pending.delete(target.id);
533
532
  this.reject(target, attribute(this.engineError(frame.error), target));
533
+ target.arrivalOrderConsumed = true;
534
+ this.retainForLateReply();
534
535
  return;
535
536
  }
536
537
  this.report(`engine sent an id-less frame with a result while '${target.method}' (id ${target.id}) is still waiting for its answer; an id-less answer from this daemon is an error frame, so this one was not delivered to any caller: ${truncate(line)}`);
@@ -629,8 +630,9 @@ var EngineJsonRpcClient = class {
629
630
  );
630
631
  return;
631
632
  }
633
+ const bodyBytes = Buffer.byteLength(jsonOrString(frame.result), "utf8");
632
634
  this.report(
633
- `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: ${truncate(jsonOrString(frame.result))}${byOrder}`
635
+ `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: the engine did answer, with a ${bodyBytes}-byte body that is raw engine content (an accessibility tree whose title/value fields are on-screen data), so its text is not written to this log${byOrder}`
634
636
  );
635
637
  if (frame.result === void 0) {
636
638
  return;
@@ -813,7 +815,7 @@ var ReconnectingSocketIo = class {
813
815
  const io = new StreamLineIo(socket, socket);
814
816
  io.onMessage((line) => this.messageHandler?.(line));
815
817
  io.onError((error) => {
816
- if (this.inner === io) {
818
+ if (!(error instanceof OversizeFrameError) && this.inner === io) {
817
819
  this.inner = null;
818
820
  io.close();
819
821
  }
@@ -1875,8 +1877,8 @@ var ProjectRegistryError = class extends Error {
1875
1877
  var PID_INT32_MAX = 2147483647;
1876
1878
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
1877
1879
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
1878
- var OptionalRoleSchema = z5.string().max(128).optional();
1879
- var OptionalBundleIdSchema = z5.string().max(256).optional();
1880
+ var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
1881
+ var OptionalBundleIdSchema = z5.string().max(512).optional();
1880
1882
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1881
1883
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
1882
1884
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
@@ -1992,7 +1994,7 @@ var TOOL_SPECS = [
1992
1994
  type: "object",
1993
1995
  additionalProperties: false,
1994
1996
  properties: {
1995
- bundleId: { type: "string", maxLength: 256 },
1997
+ bundleId: { type: "string", maxLength: 512 },
1996
1998
  pid: { type: "integer", minimum: 1, maximum: PID_INT32_MAX },
1997
1999
  projectId: { type: "string", pattern: "^prj_[0-9A-HJKMNP-TV-Z]{26}$" }
1998
2000
  },
@@ -2012,7 +2014,7 @@ var TOOL_SPECS = [
2012
2014
  additionalProperties: false,
2013
2015
  properties: {
2014
2016
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2015
- role: { type: "string", maxLength: 128 }
2017
+ role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2016
2018
  }
2017
2019
  },
2018
2020
  validate: zodBridge(ObserveArgs)
@@ -2360,6 +2362,9 @@ var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
2360
2362
  ...REPLAY_UNSAFE_METHOD_NAMES,
2361
2363
  ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
2362
2364
  ]);
2365
+ function isNarrowableByRetryMethod(method) {
2366
+ return !NOT_NARROWABLE_BY_RETRY.has(method);
2367
+ }
2363
2368
  async function readTrail(session, turn, limit) {
2364
2369
  await turn.acquire();
2365
2370
  try {
@@ -2368,6 +2373,17 @@ async function readTrail(session, turn, limit) {
2368
2373
  turn.release();
2369
2374
  }
2370
2375
  }
2376
+ function shellFaultRemedy(engineMethod) {
2377
+ const readRoute = "read what happened instead of performing it again: gp_recent_reports lists the operations this session recorded, gp_last_evidence {operationId} returns one in full, and gp_probe_status reads the daemon's own self-report on this same connection \u2014 all three are requests this shell does not treat as unsafe to repeat";
2378
+ const reportRoute = "this fault was raised inside the MCP shell, so it is in no daemon log: this process's stderr belongs to whoever started this server, and that line is the only text naming the failure, so hand it to them verbatim. A daemon restart is not authorised by an answer the daemon never made";
2379
+ if (isReplayUnsafeMethod(engineMethod)) {
2380
+ return `this is a defect in this shell, not an answer from the engine, so it measures nothing about whether '${engineMethod}' reached the daemon or the user's screen. Do NOT re-issue ${engineMethod}: a re-issue can perform the same action a second time on the user's screen (and for attach, re-point the daemon and discard the trail an outstanding request is being recorded in), and this failure does not establish that the first copy did nothing. ${readRoute}. ${reportRoute}`;
2381
+ }
2382
+ if (!isNarrowableByRetryMethod(engineMethod)) {
2383
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is a method whose re-issue writes daemon state \u2014 a fresh request appends to the bounded snapshot ring and can evict a baseline this caller already received \u2014 so treat it as a question to read around rather than one to send again: gp_snapshot lists stored ids, and ${readRoute}. ${reportRoute}`;
2384
+ }
2385
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is not one of the methods this shell classifies as unsafe to repeat (the no-replay set is act/restore/attach, plus snapshot, whose re-issue writes daemon state), so sending this tool again is a real option rather than a side effect: do that once. If the fault repeats, ${readRoute}. ${reportRoute}`;
2386
+ }
2371
2387
  var EvidenceFrameShapeError = class extends Error {
2372
2388
  constructor(detail) {
2373
2389
  super(`engine returned a last_evidence frame without an evidencePack object: ${detail}`);
@@ -2423,7 +2439,17 @@ async function captureView(args, context) {
2423
2439
  isError: true
2424
2440
  };
2425
2441
  }
2426
- const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2442
+ if (typeof body.mimeType !== "string" || body.mimeType.length === 0) {
2443
+ return {
2444
+ content: [{ type: "text", text: formatToolError(
2445
+ GP_E_INTERNAL,
2446
+ `capture_view answered without a mimeType (got ${JSON.stringify(body.mimeType ?? null)})`,
2447
+ "do not read this image as any particular format: the engine did not name one, and this shell will not fill the field in. This is a read-only capture, so sending it again performs nothing \u2014 if it repeats, report the frame shape, which is a protocol disagreement between two builds rather than a dead daemon"
2448
+ ) }],
2449
+ isError: true
2450
+ };
2451
+ }
2452
+ const mimeType = body.mimeType;
2427
2453
  const summary = canonicalJson2({
2428
2454
  mimeType,
2429
2455
  byteCount: body.byteCount,
@@ -2455,7 +2481,7 @@ async function captureView(args, context) {
2455
2481
  content: [{ type: "text", text: formatToolError(
2456
2482
  GP_E_INTERNAL,
2457
2483
  `internal shell error: ${String(error)}`,
2458
- "see the MCP server logs and retry"
2484
+ shellFaultRemedy("capture_view")
2459
2485
  ) }],
2460
2486
  isError: true
2461
2487
  };
@@ -2556,7 +2582,7 @@ function projectErrorRemedy(code) {
2556
2582
  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";
2557
2583
  }
2558
2584
  if (code === GP_E_INTERNAL) {
2559
- 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.`;
2585
+ return `the projects file is the problem, not your arguments: the message above names its path, so read it with \`/usr/bin/plutil -p <that path>\` or \`/bin/cat <that path>\` \u2014 both absolute, so they run from whatever directory the reader is in and need nothing that may be off PATH \u2014 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.`;
2560
2586
  }
2561
2587
  return "check the tool's input schema and retry";
2562
2588
  }
@@ -2631,7 +2657,7 @@ function renderTrailIds(ids, format) {
2631
2657
  }
2632
2658
  function describeFetchFailure(error) {
2633
2659
  if (error instanceof EngineCallError) {
2634
- return `${error.code}: ${error.message}`;
2660
+ return formatToolErrorShape(error.toBody());
2635
2661
  }
2636
2662
  const mapped = mapEvidenceReadError(error);
2637
2663
  if (mapped !== void 0) {
@@ -2752,8 +2778,8 @@ async function recentReports(args, context) {
2752
2778
  return {
2753
2779
  content: [{ type: "text", text: formatToolError(
2754
2780
  GP_E_NO_EVIDENCE,
2755
- "no recent evidence is reachable in the daemon history",
2756
- "the engine may have restarted; re-run gp_act / gp_assert_element to regenerate evidence"
2781
+ `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)})`,
2782
+ "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"
2757
2783
  ) }],
2758
2784
  isError: true
2759
2785
  };
@@ -2776,7 +2802,7 @@ async function recentReports(args, context) {
2776
2802
  sections2.push(body);
2777
2803
  return { content: [{ type: "text", text: sections2.join("\n") }], isError: false };
2778
2804
  }
2779
- function mapAuditError(error) {
2805
+ function mapAuditError(error, engineMethod = "last_evidence") {
2780
2806
  if (error instanceof EngineCallError) {
2781
2807
  return {
2782
2808
  content: [{ type: "text", text: formatToolErrorShape(error.toBody()) }],
@@ -2791,7 +2817,7 @@ function mapAuditError(error) {
2791
2817
  content: [{ type: "text", text: formatToolError(
2792
2818
  GP_E_INTERNAL,
2793
2819
  `internal shell error: ${String(error)}`,
2794
- "see the MCP server logs and retry"
2820
+ shellFaultRemedy(engineMethod)
2795
2821
  ) }],
2796
2822
  isError: true
2797
2823
  };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import fs5 from "node:fs";
4
5
  import process2 from "node:process";
5
6
 
6
7
  // src/canonical.ts
@@ -24,6 +25,9 @@ function sortKeys(value) {
24
25
 
25
26
  // src/tools.ts
26
27
  import { z as z5 } from "zod";
28
+ import fs4 from "node:fs";
29
+ import path4 from "node:path";
30
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
27
31
 
28
32
  // ../kernel/dist/schemas.js
29
33
  import { createRequire } from "node:module";
@@ -1189,8 +1193,9 @@ var EngineJsonRpcClient = class {
1189
1193
  return;
1190
1194
  }
1191
1195
  if (frame.error) {
1192
- this.pending.delete(target.id);
1193
1196
  this.reject(target, attribute(this.engineError(frame.error), target));
1197
+ target.arrivalOrderConsumed = true;
1198
+ this.retainForLateReply();
1194
1199
  return;
1195
1200
  }
1196
1201
  this.report(`engine sent an id-less frame with a result while '${target.method}' (id ${target.id}) is still waiting for its answer; an id-less answer from this daemon is an error frame, so this one was not delivered to any caller: ${truncate(line)}`);
@@ -1289,8 +1294,9 @@ var EngineJsonRpcClient = class {
1289
1294
  );
1290
1295
  return;
1291
1296
  }
1297
+ const bodyBytes = Buffer.byteLength(jsonOrString(frame.result), "utf8");
1292
1298
  this.report(
1293
- `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: ${truncate(jsonOrString(frame.result))}${byOrder}`
1299
+ `late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: the engine did answer, with a ${bodyBytes}-byte body that is raw engine content (an accessibility tree whose title/value fields are on-screen data), so its text is not written to this log${byOrder}`
1294
1300
  );
1295
1301
  if (frame.result === void 0) {
1296
1302
  return;
@@ -1473,7 +1479,7 @@ var ReconnectingSocketIo = class {
1473
1479
  const io = new StreamLineIo(socket, socket);
1474
1480
  io.onMessage((line) => this.messageHandler?.(line));
1475
1481
  io.onError((error) => {
1476
- if (this.inner === io) {
1482
+ if (!(error instanceof OversizeFrameError) && this.inner === io) {
1477
1483
  this.inner = null;
1478
1484
  io.close();
1479
1485
  }
@@ -1505,243 +1511,6 @@ function unixSocketEngineClient(socketPath, options = {}) {
1505
1511
  );
1506
1512
  }
1507
1513
 
1508
- // src/audit-session.ts
1509
- var AUDIT_HISTORY_LIMIT = 20;
1510
- var UNKNOWN_GENERATION = -1;
1511
- function openTrailGate() {
1512
- let settle;
1513
- const settled = new Promise((resolve) => {
1514
- settle = () => resolve(void 0);
1515
- });
1516
- return { settled, settle };
1517
- }
1518
- var EvidenceAuditSession = class {
1519
- ids = [];
1520
- epoch = 0;
1521
- attachedAppKey = null;
1522
- /**
1523
- * Turns claimed but not released. This, not a "latest gate", is what a new
1524
- * claim waits for: a turn that abandons the trail (its engine call failed
1525
- * before any mutation) releases *out of order*, and a chain that only looked
1526
- * at the immediately preceding turn would let everything behind it run while
1527
- * an older, still-outstanding act had not written yet — the exact completion
1528
- * order the turn machinery exists to remove.
1529
- */
1530
- openGates = /* @__PURE__ */ new Set();
1531
- /**
1532
- * How each id in {@link ids} got here. Keyed by the id, because that is what a
1533
- * reader holds; the map is trimmed with the list and cleared when the trail
1534
- * restarts, so an id can never outlive the provenance that describes it.
1535
- */
1536
- provenance = /* @__PURE__ */ new Map();
1537
- /**
1538
- * Non-null while {@link recordLateArrival} holds the trail, and says how the
1539
- * frame being written was attributed. This is what lets a plain
1540
- * `trail.record(result)` inside that callback be filed as a late arrival without
1541
- * the caller having to remember to say so: the write happens inside a window only
1542
- * that method opens, so the mechanism is visible here rather than being a claim
1543
- * somebody must repeat at every call site.
1544
- */
1545
- lateWindow = null;
1546
- /**
1547
- * Record every operationId/evidenceId found in an engine result frame, tagged
1548
- * with the provenance of the path that is recording it (see
1549
- * {@link TrailProvenance}): `in-order` from a call that held its own trail turn,
1550
- * `late`/`late-inferred` from inside {@link recordLateArrival}.
1551
- */
1552
- record(result) {
1553
- const source = this.lateWindow === null ? "in-order" : this.lateWindow === "arrival-order" ? "late-inferred" : "late";
1554
- for (const id of collectOperationIds(result)) {
1555
- if (!this.ids.includes(id)) {
1556
- this.ids.push(id);
1557
- this.provenance.set(id, source);
1558
- if (this.ids.length > AUDIT_HISTORY_LIMIT) {
1559
- const evicted = this.ids.splice(0, this.ids.length - AUDIT_HISTORY_LIMIT);
1560
- for (const gone of evicted) {
1561
- this.provenance.delete(gone);
1562
- }
1563
- }
1564
- }
1565
- }
1566
- }
1567
- /**
1568
- * How one id in this trail got recorded, or null when the trail does not hold
1569
- * it — never recorded, evicted past {@link AUDIT_HISTORY_LIMIT}, or cleared by a
1570
- * {@link restart}. A reader that lists ids for an agent renders
1571
- * {@link TRAIL_PROVENANCE_NOTES} for anything that is not `in-order`: an id
1572
- * pulled from a frame this shell only *guessed* an owner for must not appear
1573
- * beside one it matched by id (R10-中2).
1574
- *
1575
- * Read off the map alone, so the map *is* the record of what the trail holds: an
1576
- * id whose provenance outlives its own entry would come back labelled as if it
1577
- * were still listed, and the eviction and the restart are the two places that
1578
- * would have to remember to delete. `test/engine-client.test.mjs` pins both.
1579
- */
1580
- provenanceOf(id) {
1581
- return this.provenance.get(id) ?? null;
1582
- }
1583
- /** The ids currently filed as inferred; see {@link TrailProvenance}. */
1584
- inferredIds() {
1585
- return this.ids.filter((id) => this.provenance.get(id) === "late-inferred");
1586
- }
1587
- /**
1588
- * The trail's generation number. Every {@link restart} bumps it.
1589
- *
1590
- * It exists so a reply that arrives *after* its caller was already answered
1591
- * can be judged against the trail it was written into: {@link recordLateArrival}
1592
- * takes the epoch the request was admitted under, and a mismatch means a
1593
- * re-attach happened in between. Without it, a late write lands in a *new*
1594
- * session's trail — an operationId from the previous app that
1595
- * `gp_recent_reports` then fetches and renders as if it belonged to this one
1596
- * (R8b-高1, measured: `['op_OLDAPP']` in the trail after attaching a second
1597
- * app).
1598
- */
1599
- get generation() {
1600
- return this.epoch;
1601
- }
1602
- /** The app the trail currently belongs to, or null before the first attach. */
1603
- get attachedApp() {
1604
- return this.attachedAppKey;
1605
- }
1606
- /**
1607
- * A successful attach: the trail restarts **only when the attached app
1608
- * changed**, because that is the daemon's own rule —
1609
- * `EngineCore.attach` clears `history` under
1610
- * `if attachedApp != app`, and its comment says re-attach to the same app is
1611
- * idempotent. Clearing on *every* attach (what this used to do) is strictly
1612
- * worse in two directions: an idempotent re-attach wiped operations that were
1613
- * really in the trail (including one recorded by a late reply, which put
1614
- * `gp_recent_reports` back to "run gp_act first"), and — because the clear
1615
- * happens whenever the reply lands — it could also drop the record of an act
1616
- * the daemon still has.
1617
- *
1618
- * `appKey` must be built from every stored property of Swift's `AttachedApp`
1619
- * (`pid`, `bundleId`, `appName`); a key that ignored `pid` would keep the
1620
- * trail across an app restart whose history the daemon did clear. The gate
1621
- * that pins this to the Swift struct is
1622
- * `test/attach-identity.test.mjs`.
1623
- *
1624
- * Returns whether the trail was restarted. A null `appKey` means the attach
1625
- * result did not identify the app at all, which is treated as a change: an
1626
- * unknown identity cannot be *shown* to be the same app, and a stale trail is
1627
- * the failure that misattributes evidence.
1628
- */
1629
- restart(appKey) {
1630
- if (appKey !== null && appKey === this.attachedAppKey) {
1631
- return false;
1632
- }
1633
- this.attachedAppKey = appKey;
1634
- this.ids = [];
1635
- this.provenance.clear();
1636
- this.epoch += 1;
1637
- return true;
1638
- }
1639
- /** The most recent `limit` recorded operationIds, oldest first. */
1640
- recentIds(limit) {
1641
- return this.ids.slice(-limit);
1642
- }
1643
- /**
1644
- * Record a frame that arrives with **no request of the caller still holding a
1645
- * turn**: a late engine reply (R8-高3, `EngineJsonRpcClient.onLateReply`). Its
1646
- * own turn was released the moment the timeout was reported to the agent, so
1647
- * there is no request-order position left to write at.
1648
- *
1649
- * It still goes through the turn machinery rather than a bare `record()`,
1650
- * because a direct write could land *between* two admitted calls and reorder
1651
- * the trail against the requests the agent made — the exact thing the turns
1652
- * exist to prevent. Claiming on arrival gives the only honest position left:
1653
- * behind whatever is still outstanding, and ordered among late arrivals by
1654
- * when they arrived.
1655
- *
1656
- * `admittedUnderGeneration` is compared **inside** that wait (see the body):
1657
- * the position and the epoch can only be judged at the moment the write
1658
- * actually happens. A reply whose request carried no generation cannot be
1659
- * placed either — so it is passed as {@link UNKNOWN_GENERATION} and the caller
1660
- * decides whether that is acceptable; this method refuses it by default, because
1661
- * "we do not know which attach this belongs to" is not a reason to write into
1662
- * somebody else's trail.
1663
- *
1664
- * `attribution` is how the transport matched that frame to a request, and it is
1665
- * the difference between `late` and `late-inferred` below: a frame that echoed
1666
- * the id the client sent is confirmed, one with no id is filed against the
1667
- * oldest tracked request by the daemon's in-order scheduling (R10-中2). Anything
1668
- * this method writes is a late arrival by construction, so the window opens
1669
- * whether or not the caller can say more — the default `unknown` is what the
1670
- * session can see on its own, and it is *not* `in-order`: an unconfirmed
1671
- * attribution must not read as a verified one.
1672
- */
1673
- recordLateArrival(apply, admittedUnderGeneration, attribution = "unknown") {
1674
- const turn = this.claimTrailTurn();
1675
- return turn.acquire().then(() => {
1676
- try {
1677
- if (admittedUnderGeneration !== this.epoch) {
1678
- return { written: false, generationAtWrite: this.epoch };
1679
- }
1680
- this.lateWindow = attribution;
1681
- apply(this);
1682
- return { written: true, generationAtWrite: this.epoch };
1683
- } finally {
1684
- this.lateWindow = null;
1685
- turn.release();
1686
- }
1687
- });
1688
- }
1689
- /**
1690
- * Claim the next position in this session's trail request order, so that
1691
- * overlapping calls mutate and read `ids` in the order they were admitted
1692
- * rather than in the order the daemon answered.
1693
- *
1694
- * Synchronous on purpose — a claim made after an `await` would itself be
1695
- * ordered by completion. Callers hold a turn only across a `record()` /
1696
- * `reset()` / `recentIds()`, never across an engine round trip, so the queue
1697
- * can delay the moment a reply is built but never a request at the daemon.
1698
- */
1699
- claimTrailTurn() {
1700
- const ahead = [...this.openGates];
1701
- const mine = openTrailGate();
1702
- this.openGates.add(mine);
1703
- let released = false;
1704
- return {
1705
- acquire: async () => {
1706
- await Promise.all(ahead.map((gate) => gate.settled));
1707
- },
1708
- release: () => {
1709
- if (released) {
1710
- return;
1711
- }
1712
- released = true;
1713
- this.openGates.delete(mine);
1714
- mine.settle();
1715
- }
1716
- };
1717
- }
1718
- };
1719
- function operationIds(value) {
1720
- return collectOperationIds(value);
1721
- }
1722
- function collectOperationIds(value, depth = 0) {
1723
- const out = [];
1724
- if (depth > 3 || value === null || typeof value !== "object") {
1725
- return out;
1726
- }
1727
- if (Array.isArray(value)) {
1728
- for (const item of value) {
1729
- out.push(...collectOperationIds(item, depth + 1));
1730
- }
1731
- return out;
1732
- }
1733
- const record = value;
1734
- for (const key of Object.keys(record)) {
1735
- const entry = record[key];
1736
- if ((key === "operationId" || key === "evidenceId") && typeof entry === "string") {
1737
- out.push(entry);
1738
- } else {
1739
- out.push(...collectOperationIds(entry, depth + 1));
1740
- }
1741
- }
1742
- return out;
1743
- }
1744
-
1745
1514
  // src/evidence-report.ts
1746
1515
  var PLACEHOLDER = "\u2014";
1747
1516
  function reportTitle(pack) {
@@ -2494,8 +2263,8 @@ var ProjectRegistryError = class extends Error {
2494
2263
  var PID_INT32_MAX = 2147483647;
2495
2264
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
2496
2265
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
2497
- var OptionalRoleSchema = z5.string().max(128).optional();
2498
- var OptionalBundleIdSchema = z5.string().max(256).optional();
2266
+ var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
2267
+ var OptionalBundleIdSchema = z5.string().max(512).optional();
2499
2268
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
2500
2269
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
2501
2270
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
@@ -2611,7 +2380,7 @@ var TOOL_SPECS = [
2611
2380
  type: "object",
2612
2381
  additionalProperties: false,
2613
2382
  properties: {
2614
- bundleId: { type: "string", maxLength: 256 },
2383
+ bundleId: { type: "string", maxLength: 512 },
2615
2384
  pid: { type: "integer", minimum: 1, maximum: PID_INT32_MAX },
2616
2385
  projectId: { type: "string", pattern: "^prj_[0-9A-HJKMNP-TV-Z]{26}$" }
2617
2386
  },
@@ -2631,7 +2400,7 @@ var TOOL_SPECS = [
2631
2400
  additionalProperties: false,
2632
2401
  properties: {
2633
2402
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2634
- role: { type: "string", maxLength: 128 }
2403
+ role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2635
2404
  }
2636
2405
  },
2637
2406
  validate: zodBridge(ObserveArgs)
@@ -3047,7 +2816,28 @@ async function readTrail(session, turn, limit) {
3047
2816
  turn.release();
3048
2817
  }
3049
2818
  }
3050
- async function executeTool(spec, args, engine, session = new EvidenceAuditSession()) {
2819
+ function shellFaultRemedy(engineMethod) {
2820
+ const readRoute = "read what happened instead of performing it again: gp_recent_reports lists the operations this session recorded, gp_last_evidence {operationId} returns one in full, and gp_probe_status reads the daemon's own self-report on this same connection \u2014 all three are requests this shell does not treat as unsafe to repeat";
2821
+ const reportRoute = "this fault was raised inside the MCP shell, so it is in no daemon log: this process's stderr belongs to whoever started this server, and that line is the only text naming the failure, so hand it to them verbatim. A daemon restart is not authorised by an answer the daemon never made";
2822
+ if (isReplayUnsafeMethod(engineMethod)) {
2823
+ return `this is a defect in this shell, not an answer from the engine, so it measures nothing about whether '${engineMethod}' reached the daemon or the user's screen. Do NOT re-issue ${engineMethod}: a re-issue can perform the same action a second time on the user's screen (and for attach, re-point the daemon and discard the trail an outstanding request is being recorded in), and this failure does not establish that the first copy did nothing. ${readRoute}. ${reportRoute}`;
2824
+ }
2825
+ if (!isNarrowableByRetryMethod(engineMethod)) {
2826
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is a method whose re-issue writes daemon state \u2014 a fresh request appends to the bounded snapshot ring and can evict a baseline this caller already received \u2014 so treat it as a question to read around rather than one to send again: gp_snapshot lists stored ids, and ${readRoute}. ${reportRoute}`;
2827
+ }
2828
+ return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is not one of the methods this shell classifies as unsafe to repeat (the no-replay set is act/restore/attach, plus snapshot, whose re-issue writes daemon state), so sending this tool again is a real option rather than a side effect: do that once. If the fault repeats, ${readRoute}. ${reportRoute}`;
2829
+ }
2830
+ async function executeTool(spec, args, engine, session) {
2831
+ if (!session) {
2832
+ return {
2833
+ content: [{ type: "text", text: formatToolError(
2834
+ GP_E_INTERNAL,
2835
+ `executeTool was called for ${spec.name} without the session's trail`,
2836
+ "this shell recorded nothing for this call: executeTool requires the shared EvidenceAuditSession the server was built with (dispatch.ts's createTrackedMcpServer), because a trail handed to one call only is invisible to gp_recent_reports. Pass the server's session rather than omitting it"
2837
+ ) }],
2838
+ isError: true
2839
+ };
2840
+ }
3051
2841
  const checked = spec.validate(args);
3052
2842
  if (!checked.ok) {
3053
2843
  return {
@@ -3094,7 +2884,7 @@ async function runValidatedTool(spec, value, engine, session, turn) {
3094
2884
  content.push({
3095
2885
  type: "text",
3096
2886
  text: reading.kind === "read" ? `${reading.summary}
3097
- ${caBundleDiagnosis(reading.ca)}
2887
+ ${caBundleDiagnosis(reading.ca, updaterEnableCommand(reading.runtime))}
3098
2888
  ${runtimeSelfDiagnosis(reading.runtime, reading.stateFile)}` : reading.summary
3099
2889
  });
3100
2890
  }
@@ -3117,14 +2907,32 @@ ${runtimeSelfDiagnosis(reading.runtime, reading.stateFile)}` : reading.summary
3117
2907
  content: [{ type: "text", text: formatToolError(
3118
2908
  GP_E_INTERNAL,
3119
2909
  `internal shell error: ${String(error)}`,
3120
- "see the MCP server logs and retry"
2910
+ shellFaultRemedy(spec.engineMethod)
3121
2911
  ) }],
3122
2912
  isError: true
3123
2913
  };
3124
2914
  }
3125
2915
  }
3126
- function caBundleDiagnosis(ca) {
3127
- const enableCmd = "`node updater/cli.js enable`";
2916
+ function shellPath(text) {
2917
+ return /[\s"'`\\]/.test(text) ? JSON.stringify(text) : text;
2918
+ }
2919
+ function absoluteNodeCommand(cliPath, suffix) {
2920
+ if (cliPath === null || !path4.isAbsolute(cliPath)) {
2921
+ return null;
2922
+ }
2923
+ return `node ${shellPath(cliPath)}${suffix}`;
2924
+ }
2925
+ function repoScript(name) {
2926
+ const here = path4.dirname(fileURLToPath2(import.meta.url));
2927
+ const candidate = path4.resolve(here, "..", "..", name);
2928
+ return fs4.existsSync(candidate) ? candidate : null;
2929
+ }
2930
+ function updaterEnableCommand(runtime) {
2931
+ const named2 = runtime.recorded ? runtime.cliPath : null;
2932
+ const command = absoluteNodeCommand(named2, " enable") ?? absoluteNodeCommand(repoScript(path4.join("updater", "cli.js")), " enable");
2933
+ return command === null ? "the `enable` step of this install's updater copy \u2014 its path is the `runtime.cliPath` the record names (printed on the line below, when the record carries one), and neither that record nor this build's own location gives an absolute path, so this remedy prints no command rather than one that resolves against a directory you are not standing in" : `\`${command}\``;
2934
+ }
2935
+ function caBundleDiagnosis(ca, enableCmd) {
3128
2936
  if (!ca.recorded) {
3129
2937
  return `ca trust bundle: this install has never recorded exporting the machine's trusted roots (the state file carries no caRoots), which is NOT the same as "the bundle is fine" \u2014 an install predating the export step looks exactly like this. node then verifies only against the CA list inside its own binary, so on a machine whose HTTPS is re-signed locally every auto-update run fails certificate verification and nothing on the surface says so. Remedy: re-run the GlassPane installer, or ${enableCmd}, either of which writes the roots to ${ca.bundlePath} and points the launchd agent and the panel at it. For a check run by hand in a terminal: export NODE_EXTRA_CA_CERTS=${ca.bundlePath} \u2014 but only once that file exists, and set before node starts, because a running process never picks the variable up.`;
3130
2938
  }
@@ -3161,8 +2969,10 @@ function caBundleDiagnosis(ca) {
3161
2969
  return provenance.length === 0 ? diagnosis : `${diagnosis} The record itself says: ${provenance.join("; ")}.`;
3162
2970
  }
3163
2971
  function runtimeSelfDiagnosis(runtime, stateFile) {
3164
- const installerCmd = "`node installer/cli.js` (or the `install.sh` this machine was installed with)";
3165
- const enableCmd = (cliPath) => cliPath === null ? null : `node ${cliPath} enable --state-dir ${stateRootOf(stateFile)} --json`;
2972
+ const installerCommand = absoluteNodeCommand(repoScript("installer/cli.js"), "");
2973
+ const installScript = repoScript("install.sh");
2974
+ const installerCmd = installerCommand === null ? "the installer of the GlassPane checkout this machine was installed from \u2014 this build is not shipped beside it, so no absolute path can be named from here, and every command in that tree has to be run from inside it" : installScript === null ? `\`${installerCommand}\`` : `\`${installerCommand}\` (or \`${shellPath(installScript)}\`)`;
2975
+ const enableCmd = (cliPath) => absoluteNodeCommand(cliPath, ` enable --state-dir ${shellPath(stateRootOf(stateFile))} --json`);
3166
2976
  const copy = (version, cliPath) => {
3167
2977
  if (cliPath === null) {
3168
2978
  return version === null ? "the record names neither a version nor a path for it" : `version ${version}, and the record names no path for it`;
@@ -3260,7 +3070,17 @@ async function captureView(args, context) {
3260
3070
  isError: true
3261
3071
  };
3262
3072
  }
3263
- const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
3073
+ if (typeof body.mimeType !== "string" || body.mimeType.length === 0) {
3074
+ return {
3075
+ content: [{ type: "text", text: formatToolError(
3076
+ GP_E_INTERNAL,
3077
+ `capture_view answered without a mimeType (got ${JSON.stringify(body.mimeType ?? null)})`,
3078
+ "do not read this image as any particular format: the engine did not name one, and this shell will not fill the field in. This is a read-only capture, so sending it again performs nothing \u2014 if it repeats, report the frame shape, which is a protocol disagreement between two builds rather than a dead daemon"
3079
+ ) }],
3080
+ isError: true
3081
+ };
3082
+ }
3083
+ const mimeType = body.mimeType;
3264
3084
  const summary = canonicalJson({
3265
3085
  mimeType,
3266
3086
  byteCount: body.byteCount,
@@ -3292,7 +3112,7 @@ async function captureView(args, context) {
3292
3112
  content: [{ type: "text", text: formatToolError(
3293
3113
  GP_E_INTERNAL,
3294
3114
  `internal shell error: ${String(error)}`,
3295
- "see the MCP server logs and retry"
3115
+ shellFaultRemedy("capture_view")
3296
3116
  ) }],
3297
3117
  isError: true
3298
3118
  };
@@ -3393,7 +3213,7 @@ function projectErrorRemedy(code) {
3393
3213
  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";
3394
3214
  }
3395
3215
  if (code === GP_E_INTERNAL) {
3396
- 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.`;
3216
+ return `the projects file is the problem, not your arguments: the message above names its path, so read it with \`/usr/bin/plutil -p <that path>\` or \`/bin/cat <that path>\` \u2014 both absolute, so they run from whatever directory the reader is in and need nothing that may be off PATH \u2014 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.`;
3397
3217
  }
3398
3218
  return "check the tool's input schema and retry";
3399
3219
  }
@@ -3468,7 +3288,7 @@ function renderTrailIds(ids, format) {
3468
3288
  }
3469
3289
  function describeFetchFailure(error) {
3470
3290
  if (error instanceof EngineCallError) {
3471
- return `${error.code}: ${error.message}`;
3291
+ return formatToolErrorShape(error.toBody());
3472
3292
  }
3473
3293
  const mapped = mapEvidenceReadError(error);
3474
3294
  if (mapped !== void 0) {
@@ -3589,8 +3409,8 @@ async function recentReports(args, context) {
3589
3409
  return {
3590
3410
  content: [{ type: "text", text: formatToolError(
3591
3411
  GP_E_NO_EVIDENCE,
3592
- "no recent evidence is reachable in the daemon history",
3593
- "the engine may have restarted; re-run gp_act / gp_assert_element to regenerate evidence"
3412
+ `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)})`,
3413
+ "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"
3594
3414
  ) }],
3595
3415
  isError: true
3596
3416
  };
@@ -3613,7 +3433,7 @@ async function recentReports(args, context) {
3613
3433
  sections2.push(body);
3614
3434
  return { content: [{ type: "text", text: sections2.join("\n") }], isError: false };
3615
3435
  }
3616
- function mapAuditError(error) {
3436
+ function mapAuditError(error, engineMethod = "last_evidence") {
3617
3437
  if (error instanceof EngineCallError) {
3618
3438
  return {
3619
3439
  content: [{ type: "text", text: formatToolErrorShape(error.toBody()) }],
@@ -3628,7 +3448,7 @@ function mapAuditError(error) {
3628
3448
  content: [{ type: "text", text: formatToolError(
3629
3449
  GP_E_INTERNAL,
3630
3450
  `internal shell error: ${String(error)}`,
3631
- "see the MCP server logs and retry"
3451
+ shellFaultRemedy(engineMethod)
3632
3452
  ) }],
3633
3453
  isError: true
3634
3454
  };
@@ -3657,6 +3477,243 @@ function engineWithPayloadAdvice(engine, spec, toolArgs = {}) {
3657
3477
  return wrapped;
3658
3478
  }
3659
3479
 
3480
+ // src/audit-session.ts
3481
+ var AUDIT_HISTORY_LIMIT = 20;
3482
+ var UNKNOWN_GENERATION = -1;
3483
+ function openTrailGate() {
3484
+ let settle;
3485
+ const settled = new Promise((resolve) => {
3486
+ settle = () => resolve(void 0);
3487
+ });
3488
+ return { settled, settle };
3489
+ }
3490
+ var EvidenceAuditSession = class {
3491
+ ids = [];
3492
+ epoch = 0;
3493
+ attachedAppKey = null;
3494
+ /**
3495
+ * Turns claimed but not released. This, not a "latest gate", is what a new
3496
+ * claim waits for: a turn that abandons the trail (its engine call failed
3497
+ * before any mutation) releases *out of order*, and a chain that only looked
3498
+ * at the immediately preceding turn would let everything behind it run while
3499
+ * an older, still-outstanding act had not written yet — the exact completion
3500
+ * order the turn machinery exists to remove.
3501
+ */
3502
+ openGates = /* @__PURE__ */ new Set();
3503
+ /**
3504
+ * How each id in {@link ids} got here. Keyed by the id, because that is what a
3505
+ * reader holds; the map is trimmed with the list and cleared when the trail
3506
+ * restarts, so an id can never outlive the provenance that describes it.
3507
+ */
3508
+ provenance = /* @__PURE__ */ new Map();
3509
+ /**
3510
+ * Non-null while {@link recordLateArrival} holds the trail, and says how the
3511
+ * frame being written was attributed. This is what lets a plain
3512
+ * `trail.record(result)` inside that callback be filed as a late arrival without
3513
+ * the caller having to remember to say so: the write happens inside a window only
3514
+ * that method opens, so the mechanism is visible here rather than being a claim
3515
+ * somebody must repeat at every call site.
3516
+ */
3517
+ lateWindow = null;
3518
+ /**
3519
+ * Record every operationId/evidenceId found in an engine result frame, tagged
3520
+ * with the provenance of the path that is recording it (see
3521
+ * {@link TrailProvenance}): `in-order` from a call that held its own trail turn,
3522
+ * `late`/`late-inferred` from inside {@link recordLateArrival}.
3523
+ */
3524
+ record(result) {
3525
+ const source = this.lateWindow === null ? "in-order" : this.lateWindow === "arrival-order" ? "late-inferred" : "late";
3526
+ for (const id of collectOperationIds(result)) {
3527
+ if (!this.ids.includes(id)) {
3528
+ this.ids.push(id);
3529
+ this.provenance.set(id, source);
3530
+ if (this.ids.length > AUDIT_HISTORY_LIMIT) {
3531
+ const evicted = this.ids.splice(0, this.ids.length - AUDIT_HISTORY_LIMIT);
3532
+ for (const gone of evicted) {
3533
+ this.provenance.delete(gone);
3534
+ }
3535
+ }
3536
+ }
3537
+ }
3538
+ }
3539
+ /**
3540
+ * How one id in this trail got recorded, or null when the trail does not hold
3541
+ * it — never recorded, evicted past {@link AUDIT_HISTORY_LIMIT}, or cleared by a
3542
+ * {@link restart}. A reader that lists ids for an agent renders
3543
+ * {@link TRAIL_PROVENANCE_NOTES} for anything that is not `in-order`: an id
3544
+ * pulled from a frame this shell only *guessed* an owner for must not appear
3545
+ * beside one it matched by id (R10-中2).
3546
+ *
3547
+ * Read off the map alone, so the map *is* the record of what the trail holds: an
3548
+ * id whose provenance outlives its own entry would come back labelled as if it
3549
+ * were still listed, and the eviction and the restart are the two places that
3550
+ * would have to remember to delete. `test/engine-client.test.mjs` pins both.
3551
+ */
3552
+ provenanceOf(id) {
3553
+ return this.provenance.get(id) ?? null;
3554
+ }
3555
+ /** The ids currently filed as inferred; see {@link TrailProvenance}. */
3556
+ inferredIds() {
3557
+ return this.ids.filter((id) => this.provenance.get(id) === "late-inferred");
3558
+ }
3559
+ /**
3560
+ * The trail's generation number. Every {@link restart} bumps it.
3561
+ *
3562
+ * It exists so a reply that arrives *after* its caller was already answered
3563
+ * can be judged against the trail it was written into: {@link recordLateArrival}
3564
+ * takes the epoch the request was admitted under, and a mismatch means a
3565
+ * re-attach happened in between. Without it, a late write lands in a *new*
3566
+ * session's trail — an operationId from the previous app that
3567
+ * `gp_recent_reports` then fetches and renders as if it belonged to this one
3568
+ * (R8b-高1, measured: `['op_OLDAPP']` in the trail after attaching a second
3569
+ * app).
3570
+ */
3571
+ get generation() {
3572
+ return this.epoch;
3573
+ }
3574
+ /** The app the trail currently belongs to, or null before the first attach. */
3575
+ get attachedApp() {
3576
+ return this.attachedAppKey;
3577
+ }
3578
+ /**
3579
+ * A successful attach: the trail restarts **only when the attached app
3580
+ * changed**, because that is the daemon's own rule —
3581
+ * `EngineCore.attach` clears `history` under
3582
+ * `if attachedApp != app`, and its comment says re-attach to the same app is
3583
+ * idempotent. Clearing on *every* attach (what this used to do) is strictly
3584
+ * worse in two directions: an idempotent re-attach wiped operations that were
3585
+ * really in the trail (including one recorded by a late reply, which put
3586
+ * `gp_recent_reports` back to "run gp_act first"), and — because the clear
3587
+ * happens whenever the reply lands — it could also drop the record of an act
3588
+ * the daemon still has.
3589
+ *
3590
+ * `appKey` must be built from every stored property of Swift's `AttachedApp`
3591
+ * (`pid`, `bundleId`, `appName`); a key that ignored `pid` would keep the
3592
+ * trail across an app restart whose history the daemon did clear. The gate
3593
+ * that pins this to the Swift struct is
3594
+ * `test/attach-identity.test.mjs`.
3595
+ *
3596
+ * Returns whether the trail was restarted. A null `appKey` means the attach
3597
+ * result did not identify the app at all, which is treated as a change: an
3598
+ * unknown identity cannot be *shown* to be the same app, and a stale trail is
3599
+ * the failure that misattributes evidence.
3600
+ */
3601
+ restart(appKey) {
3602
+ if (appKey !== null && appKey === this.attachedAppKey) {
3603
+ return false;
3604
+ }
3605
+ this.attachedAppKey = appKey;
3606
+ this.ids = [];
3607
+ this.provenance.clear();
3608
+ this.epoch += 1;
3609
+ return true;
3610
+ }
3611
+ /** The most recent `limit` recorded operationIds, oldest first. */
3612
+ recentIds(limit) {
3613
+ return this.ids.slice(-limit);
3614
+ }
3615
+ /**
3616
+ * Record a frame that arrives with **no request of the caller still holding a
3617
+ * turn**: a late engine reply (R8-高3, `EngineJsonRpcClient.onLateReply`). Its
3618
+ * own turn was released the moment the timeout was reported to the agent, so
3619
+ * there is no request-order position left to write at.
3620
+ *
3621
+ * It still goes through the turn machinery rather than a bare `record()`,
3622
+ * because a direct write could land *between* two admitted calls and reorder
3623
+ * the trail against the requests the agent made — the exact thing the turns
3624
+ * exist to prevent. Claiming on arrival gives the only honest position left:
3625
+ * behind whatever is still outstanding, and ordered among late arrivals by
3626
+ * when they arrived.
3627
+ *
3628
+ * `admittedUnderGeneration` is compared **inside** that wait (see the body):
3629
+ * the position and the epoch can only be judged at the moment the write
3630
+ * actually happens. A reply whose request carried no generation cannot be
3631
+ * placed either — so it is passed as {@link UNKNOWN_GENERATION} and the caller
3632
+ * decides whether that is acceptable; this method refuses it by default, because
3633
+ * "we do not know which attach this belongs to" is not a reason to write into
3634
+ * somebody else's trail.
3635
+ *
3636
+ * `attribution` is how the transport matched that frame to a request, and it is
3637
+ * the difference between `late` and `late-inferred` below: a frame that echoed
3638
+ * the id the client sent is confirmed, one with no id is filed against the
3639
+ * oldest tracked request by the daemon's in-order scheduling (R10-中2). Anything
3640
+ * this method writes is a late arrival by construction, so the window opens
3641
+ * whether or not the caller can say more — the default `unknown` is what the
3642
+ * session can see on its own, and it is *not* `in-order`: an unconfirmed
3643
+ * attribution must not read as a verified one.
3644
+ */
3645
+ recordLateArrival(apply, admittedUnderGeneration, attribution = "unknown") {
3646
+ const turn = this.claimTrailTurn();
3647
+ return turn.acquire().then(() => {
3648
+ try {
3649
+ if (admittedUnderGeneration !== this.epoch) {
3650
+ return { written: false, generationAtWrite: this.epoch };
3651
+ }
3652
+ this.lateWindow = attribution;
3653
+ apply(this);
3654
+ return { written: true, generationAtWrite: this.epoch };
3655
+ } finally {
3656
+ this.lateWindow = null;
3657
+ turn.release();
3658
+ }
3659
+ });
3660
+ }
3661
+ /**
3662
+ * Claim the next position in this session's trail request order, so that
3663
+ * overlapping calls mutate and read `ids` in the order they were admitted
3664
+ * rather than in the order the daemon answered.
3665
+ *
3666
+ * Synchronous on purpose — a claim made after an `await` would itself be
3667
+ * ordered by completion. Callers hold a turn only across a `record()` /
3668
+ * `reset()` / `recentIds()`, never across an engine round trip, so the queue
3669
+ * can delay the moment a reply is built but never a request at the daemon.
3670
+ */
3671
+ claimTrailTurn() {
3672
+ const ahead = [...this.openGates];
3673
+ const mine = openTrailGate();
3674
+ this.openGates.add(mine);
3675
+ let released = false;
3676
+ return {
3677
+ acquire: async () => {
3678
+ await Promise.all(ahead.map((gate) => gate.settled));
3679
+ },
3680
+ release: () => {
3681
+ if (released) {
3682
+ return;
3683
+ }
3684
+ released = true;
3685
+ this.openGates.delete(mine);
3686
+ mine.settle();
3687
+ }
3688
+ };
3689
+ }
3690
+ };
3691
+ function operationIds(value) {
3692
+ return collectOperationIds(value);
3693
+ }
3694
+ function collectOperationIds(value, depth = 0) {
3695
+ const out = [];
3696
+ if (depth > 3 || value === null || typeof value !== "object") {
3697
+ return out;
3698
+ }
3699
+ if (Array.isArray(value)) {
3700
+ for (const item of value) {
3701
+ out.push(...collectOperationIds(item, depth + 1));
3702
+ }
3703
+ return out;
3704
+ }
3705
+ const record = value;
3706
+ for (const key of Object.keys(record)) {
3707
+ const entry = record[key];
3708
+ if ((key === "operationId" || key === "evidenceId") && typeof entry === "string") {
3709
+ out.push(entry);
3710
+ } else {
3711
+ out.push(...collectOperationIds(entry, depth + 1));
3712
+ }
3713
+ }
3714
+ return out;
3715
+ }
3716
+
3660
3717
  // src/dispatch.ts
3661
3718
  var JSONRPC = "2.0";
3662
3719
  var MCP_PROTOCOL_VERSION = "2025-06-18";
@@ -3665,7 +3722,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3665
3722
  "2025-03-26",
3666
3723
  MCP_PROTOCOL_VERSION
3667
3724
  ];
3668
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.10.0" };
3725
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.12.0" };
3669
3726
  var PARSE_ERROR = -32700;
3670
3727
  var INVALID_REQUEST = -32600;
3671
3728
  var METHOD_NOT_FOUND = -32601;
@@ -3900,6 +3957,32 @@ A daemon started by hand with --state-dir has no launchd job to read: pass it
3900
3957
  <state-dir>/daemon.sock.
3901
3958
  `;
3902
3959
  var DRAIN_WAIT_MS = 3e4;
3960
+ var WRITE_STALL_MS = 10;
3961
+ var WRITE_STALL_LIMIT = 1e3;
3962
+ function writeDescriptor(fd, text) {
3963
+ const buffer = Buffer.from(text, "utf8");
3964
+ let offset = 0;
3965
+ let stalls = 0;
3966
+ while (offset < buffer.length) {
3967
+ let written;
3968
+ try {
3969
+ written = fs5.writeSync(fd, buffer, offset, buffer.length - offset);
3970
+ } catch (error) {
3971
+ const code = error.code;
3972
+ if (code === "EAGAIN" && stalls < WRITE_STALL_LIMIT) {
3973
+ stalls++;
3974
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, WRITE_STALL_MS);
3975
+ continue;
3976
+ }
3977
+ return;
3978
+ }
3979
+ if (written <= 0) {
3980
+ return;
3981
+ }
3982
+ offset += written;
3983
+ stalls = 0;
3984
+ }
3985
+ }
3903
3986
  function logNote(text) {
3904
3987
  try {
3905
3988
  process2.stderr.write(`[glasspane-mcp] ${text}
@@ -3922,11 +4005,11 @@ function main() {
3922
4005
  try {
3923
4006
  options = parseArgs(process2.argv.slice(2));
3924
4007
  } catch (error) {
3925
- process2.stderr.write(String(error) + "\n\n" + USAGE);
4008
+ writeDescriptor(2, String(error) + "\n\n" + USAGE);
3926
4009
  process2.exit(2);
3927
4010
  }
3928
4011
  if (options.help) {
3929
- process2.stdout.write(USAGE);
4012
+ writeDescriptor(1, USAGE);
3930
4013
  process2.exit(0);
3931
4014
  }
3932
4015
  process2.stdin.setEncoding("utf8");
@@ -3936,7 +4019,8 @@ function main() {
3936
4019
  identity: { version: SERVER_INFO.version }
3937
4020
  });
3938
4021
  } catch (error) {
3939
- process2.stderr.write(
4022
+ writeDescriptor(
4023
+ 2,
3940
4024
  `failed to create engine client on ${options.socketPath}: ${String(error)}
3941
4025
  `
3942
4026
  );
@@ -3977,7 +4061,7 @@ function main() {
3977
4061
  process2.stdin.on("data", (chunk) => reader.push(chunk));
3978
4062
  process2.stdin.on("error", (error) => {
3979
4063
  logNote(`stdin failed: ${error.message}`);
3980
- shutdown();
4064
+ queue.push(async () => shutdown());
3981
4065
  });
3982
4066
  process2.stdin.on("close", () => queue.push(async () => shutdown()));
3983
4067
  process2.on("SIGINT", shutdown);
package/dist/tools.d.ts CHANGED
@@ -373,6 +373,24 @@ export declare function isNarrowableByRetryMethod(method: string): boolean;
373
373
  * is the transport's, which orders no re-send.
374
374
  */
375
375
  export declare function toolAuthorsTimeoutAdvice(spec: ToolSpec, method: string, params: Record<string, unknown>): boolean;
376
+ /**
377
+ * The remedy for a fault raised by *this shell*, on a call to `engineMethod`.
378
+ *
379
+ * This replaced "see the MCP server logs and retry" at all three internal-fault
380
+ * sites, and both halves of that sentence were wrong (review 2026-10-09 finding
381
+ * 8). "See the MCP server logs" is not something the caller can do: this process's
382
+ * stderr is held by whoever started the server, and a fault raised here is in no
383
+ * daemon log either, so the instruction sent the agent to look at a file that
384
+ * cannot contain the answer. And "retry" is an order to put a second copy of the
385
+ * request on the wire for methods whose re-issue this shell already refuses to
386
+ * endorse — `act`/`restore`/`attach` may still be performing their change on the
387
+ * user's screen, and a shell-side fault says nothing about whether the first copy
388
+ * went out. The classification is read from the transport's own lists
389
+ * ({@link isReplayUnsafeMethod}, {@link isNarrowableByRetryMethod}) rather than a
390
+ * third copy of the names, which is what keeps this answer from drifting back into
391
+ * "just retry the click".
392
+ */
393
+ export declare function shellFaultRemedy(engineMethod: string): string;
376
394
  /**
377
395
  * Execute a tool against the engine client. Validates arguments against
378
396
  * the tool's zod schema (isError + GP_E_BAD_PARAMS on failure), then either
@@ -383,7 +401,20 @@ export declare function toolAuthorsTimeoutAdvice(spec: ToolSpec, method: string,
383
401
  * before it does (see the trail-ordering block above); the engine call that
384
402
  * precedes that wait stays concurrent with every other outstanding request.
385
403
  */
386
- export declare function executeTool(spec: ToolSpec, args: unknown, engine: EngineJsonRpcClient, session?: EvidenceAuditSession): Promise<ToolResult>;
404
+ export declare function executeTool(spec: ToolSpec, args: unknown, engine: EngineJsonRpcClient,
405
+ /**
406
+ * The session whose trail this call records into and reads back. **Required**,
407
+ * and it used to be `= new EvidenceAuditSession()`: a caller that omitted the
408
+ * argument then recorded its `operationId` into a trail nobody else held a
409
+ * reference to, so the next `gp_recent_reports` answered
410
+ * `GP_E_NO_EVIDENCE … no operations recorded in this session` for an `act` that
411
+ * had really run — the reading that makes an agent press it a second time on the
412
+ * user's screen. That is the same defect `dispatch.ts`'s `McpServerDeps.session`
413
+ * documents as removed, and it is checked at runtime for the same reason: every
414
+ * meaningful caller here is JavaScript (`dist` consumers, the smoke scripts, the
415
+ * tests), where an omitted argument compiles forever and fails silently.
416
+ */
417
+ session: EvidenceAuditSession): Promise<ToolResult>;
387
418
  /**
388
419
  * Strongly validates the engine's last_evidence result (§6.3) and returns the
389
420
  * parsed pack together with the schema label the archive *carried*. The engine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "GlassPane MCP stdio shell: JSON-RPC 2.0 over stdio bridging an AI agent to the glasspane engine daemon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",