glasspane-mcp 1.12.2 → 1.13.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.12.2";
29
+ readonly version: "1.13.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -140,7 +140,16 @@ export declare function isReplayUnsafeMethod(method: string): boolean;
140
140
  /** The non-replayable methods, for gates that must see the set change on purpose. */
141
141
  export declare const REPLAY_UNSAFE_METHOD_NAMES: readonly string[];
142
142
  /**
143
- * Methods whose re-issue writes daemon state the *caller* depends on, even though
143
+ * `hello.capabilities` 的第一个读者(2026-10-11)。
144
+ *
145
+ * 这份清单由 daemon 从 `EngineMethod` 派生(`FrameCodec.capabilityWireNames`),可此前
146
+ * **没有任何一方读它**:面板的解析器只取 version/protocolVersion/pid/identity/permissions,
147
+ * 这一层只比 protocolVersion。没有读者就等于没有镜像——三处真源(kernel schema、daemon 方法表、
148
+ * 本壳的 TOOL_SPECS)漂了也不会有人说一句,而"清单存在但没人核"正是这份代码在别处反复拒绝的
149
+ * 那种自证。返回 null 表示无话可说(两边一致),否则给出要说的那句话。
150
+ */
151
+ export declare function describeCapabilityDrift(advertised: readonly string[] | null, forwarded: readonly string[]): string | null;
152
+ /**
144
153
  * replaying them is not unsafe in the sense {@link REPLAY_UNSAFE_REASONS} means
145
154
  * (no operation of the user's is performed a second time). They are the other half
146
155
  * of the answer to "may this be narrowed by sending it again?", and they are kept
@@ -309,6 +318,14 @@ export declare class EngineJsonRpcClient {
309
318
  private readonly callerCeilingMs;
310
319
  /** Whom a timeout's remedy is being written for; see {@link ShellSurface}. */
311
320
  private readonly surface;
321
+ /**
322
+ * The daemon methods this shell forwards, injected by the composition root
323
+ * from `TOOL_SPECS` (never hand-copied — a copied list is the drift this
324
+ * exists to catch). Empty means "no expectation was wired", and the
325
+ * reconciliation stays silent rather than reporting every capability as
326
+ * unexposed; `index.ts` supplies the real list.
327
+ */
328
+ private readonly expectedDaemonMethods;
312
329
  /**
313
330
  * Every mutable piece of this client's state, in one object that is *never*
314
331
  * reassigned through `this`.
@@ -339,7 +356,15 @@ export declare class EngineJsonRpcClient {
339
356
  */
340
357
  callerCeilingMs?: number,
341
358
  /** Whom a timeout's remedy is being written for; see {@link ShellSurface}. */
342
- surface?: ShellSurface);
359
+ surface?: ShellSurface,
360
+ /**
361
+ * The daemon methods this shell forwards, injected by the composition root
362
+ * from `TOOL_SPECS` (never hand-copied — a copied list is the drift this
363
+ * exists to catch). Empty means "no expectation was wired", and the
364
+ * reconciliation stays silent rather than reporting every capability as
365
+ * unexposed; `index.ts` supplies the real list.
366
+ */
367
+ expectedDaemonMethods?: readonly string[]);
343
368
  /**
344
369
  * Sink for facts that cannot be attached to any request of the caller: a
345
370
  * late reply, a frame with an id this client never issued, a daemon that
@@ -462,6 +487,8 @@ export declare function unixSocketEngineClient(socketPath: string, options?: {
462
487
  callerCeilingMs?: number;
463
488
  /** Pass `"http"` from the gateway: its callers have no operation trail. */
464
489
  surface?: ShellSurface;
490
+ /** Daemon methods this shell forwards; see {@link EngineJsonRpcClient}'s last parameter. */
491
+ expectedDaemonMethods?: readonly string[];
465
492
  }): EngineJsonRpcClient;
466
493
  /**
467
494
  * Build a client over an existing LineIo (used by tests with a fake
package/dist/errors.d.ts CHANGED
@@ -9,6 +9,12 @@ export declare const GP_E_NO_EVIDENCE = "GP_E_NO_EVIDENCE";
9
9
  export declare const GP_E_INTERNAL = "GP_E_INTERNAL";
10
10
  export declare const GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
11
11
  export declare const GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
12
+ /** Shell-only: the table changed between our read and our write, so we refused
13
+ * to overwrite. The daemon has no such code (it owns one writer), and every other
14
+ * code this shell raises for itself is listed with it in
15
+ * `test/remedy-surface.test.mjs`'s SHELL_ONLY set — a code outside the vocabulary
16
+ * with no remedy arm reads to an agent as "retry the same write". */
17
+ export declare const GP_E_REGISTRY_DRIFT = "GP_E_REGISTRY_DRIFT";
12
18
  /**
13
19
  * Codes this shell raises for itself, in one place so a string literal cannot
14
20
  * exist twice with different spellings (X-15).
@@ -141,6 +141,7 @@ var StreamLineIo = class {
141
141
  this.input.destroy();
142
142
  }
143
143
  };
144
+ var STDIO_WRITE_BACKLOG_BYTES = 32 * 1024 * 1024;
144
145
 
145
146
  // src/errors.ts
146
147
  var GP_E_ENGINE_UNREACHABLE = "GP_E_ENGINE_UNREACHABLE";
@@ -149,6 +150,7 @@ var GP_E_NO_EVIDENCE = "GP_E_NO_EVIDENCE";
149
150
  var GP_E_INTERNAL = "GP_E_INTERNAL";
150
151
  var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
151
152
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
153
+ var GP_E_REGISTRY_DRIFT = "GP_E_REGISTRY_DRIFT";
152
154
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
153
155
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
154
156
  var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
@@ -223,6 +225,24 @@ function isReplayUnsafeMethod(method) {
223
225
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
224
226
  }
225
227
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
228
+ function describeCapabilityDrift(advertised, forwarded) {
229
+ if (advertised === null) {
230
+ return "engine self-report carries no capabilities list, so this shell cannot say which daemon methods it measured; every tool result in this session is unverified against the daemon's own method table (an engine older than the capability list, or a hello that was cut short)";
231
+ }
232
+ const missing = forwarded.filter((method) => !advertised.includes(method));
233
+ const unexposed = advertised.filter((method) => !forwarded.includes(method));
234
+ if (missing.length === 0 && unexposed.length === 0) {
235
+ return null;
236
+ }
237
+ const parts = [];
238
+ if (missing.length > 0) {
239
+ parts.push(`this shell forwards ${missing.join(", ")} but the daemon's hello does not list it/them \u2014 that call will be answered methodNotFound, so the tool surface and the engine method table have drifted`);
240
+ }
241
+ if (unexposed.length > 0) {
242
+ parts.push(`the daemon offers ${unexposed.join(", ")} which this shell does not expose as a tool \u2014 not an error, but the agent cannot reach it from MCP`);
243
+ }
244
+ return `capability reconciliation: ${parts.join("; ")}`;
245
+ }
226
246
  var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
227
247
  function engineDeadlineMs(method, params) {
228
248
  if (method === "restore") {
@@ -307,12 +327,13 @@ function slowEngineRemedy(method, surface = "mcp") {
307
327
  var LATE_REPLY_RETENTION = 16;
308
328
  var LOG_BODY_CHARS = 64 * 1024;
309
329
  var EngineJsonRpcClient = class {
310
- constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp") {
330
+ constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp", expectedDaemonMethods = []) {
311
331
  this.io = io;
312
332
  this.timeoutMs = timeoutMs;
313
333
  this.identity = identity;
314
334
  this.callerCeilingMs = callerCeilingMs;
315
335
  this.surface = surface;
336
+ this.expectedDaemonMethods = expectedDaemonMethods;
316
337
  this.io.onMessage((line) => this.handleMessage(line));
317
338
  this.io.onError((error) => {
318
339
  if (error instanceof OversizeFrameError) {
@@ -337,6 +358,7 @@ var EngineJsonRpcClient = class {
337
358
  identity;
338
359
  callerCeilingMs;
339
360
  surface;
361
+ expectedDaemonMethods;
340
362
  /**
341
363
  * Every mutable piece of this client's state, in one object that is *never*
342
364
  * reassigned through `this`.
@@ -753,6 +775,13 @@ var EngineJsonRpcClient = class {
753
775
  if (version !== null && wantVersion !== void 0 && version !== wantVersion) {
754
776
  this.report(`version mismatch: engine self-reports '${version}', this MCP shell ships '${wantVersion}' (single version line) \u2014 one side is stale`);
755
777
  }
778
+ if (this.expectedDaemonMethods.length > 0) {
779
+ const advertised = Array.isArray(report.capabilities) ? report.capabilities.filter((item) => typeof item === "string") : null;
780
+ const drift = describeCapabilityDrift(advertised, this.expectedDaemonMethods);
781
+ if (drift !== null) {
782
+ this.report(drift);
783
+ }
784
+ }
756
785
  }
757
786
  };
758
787
  function attribute(error, entry, inferred = true) {
@@ -847,7 +876,8 @@ function unixSocketEngineClient(socketPath, options = {}) {
847
876
  void 0,
848
877
  options.identity ?? null,
849
878
  options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS,
850
- options.surface ?? "mcp"
879
+ options.surface ?? "mcp",
880
+ options.expectedDaemonMethods ?? []
851
881
  );
852
882
  }
853
883
 
@@ -1569,7 +1599,7 @@ function registryDriftReason(filePath, openedAs, now) {
1569
1599
  function assertNoConcurrentWrite(filePath, openedAs) {
1570
1600
  const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1571
1601
  if (reason !== null) {
1572
- throw new ProjectRegistryError("registry-changed-underneath", reason);
1602
+ throw new ProjectRegistryError(GP_E_REGISTRY_DRIFT, reason);
1573
1603
  }
1574
1604
  }
1575
1605
  function saveProjects(filePath, entries) {
@@ -1887,7 +1917,7 @@ var ProjectRegistryError = class extends Error {
1887
1917
  var PID_INT32_MAX = 2147483647;
1888
1918
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
1889
1919
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
1890
- var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
1920
+ var OptionalRoleSchema = z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional();
1891
1921
  var OptionalBundleIdSchema = z5.string().max(512).optional();
1892
1922
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1893
1923
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
@@ -2269,6 +2299,7 @@ var TOOL_SPECS = [
2269
2299
  execute: projectGetTool
2270
2300
  }
2271
2301
  ];
2302
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2272
2303
  var TOOL_BY_NAME = new Map(
2273
2304
  TOOL_SPECS.map((spec) => [spec.name, spec])
2274
2305
  );
@@ -2588,6 +2619,9 @@ function projectErrorRemedy(code) {
2588
2619
  if (code === GP_E_NOT_FOUND) {
2589
2620
  return "check the projectId; use gp_project_list to view available projects";
2590
2621
  }
2622
+ if (code === GP_E_REGISTRY_DRIFT) {
2623
+ return "the projects file changed between the read that produced this reply and the write it attempted, so nothing was written: re-run the same gp_project_set \u2014 what this refusal wants is a second read, not different arguments. If it repeats, another writer owns that file (the daemon's maintenance CLI and the settings panel both rewrite it): read the table with `glasspaned --list-projects`, and remember a removal only sticks after the daemon restarts. `GLASSPANE_PROJECTS_FORCE_OVERWRITE=1` moves a file aside before writing, and nothing is overwritten without it.";
2624
+ }
2591
2625
  if (code === GP_E_NO_USER_RECORD) {
2592
2626
  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";
2593
2627
  }
@@ -3433,6 +3467,8 @@ function httpStatusForEngineCode(code) {
3433
3467
  return 503;
3434
3468
  case GP_E_ENGINE_TIMEOUT:
3435
3469
  return 504;
3470
+ case GP_E_REGISTRY_DRIFT:
3471
+ return 503;
3436
3472
  case GP_E_PAYLOAD_TOO_LARGE:
3437
3473
  return 502;
3438
3474
  case GP_E_INTERNAL:
@@ -141,6 +141,7 @@ var StreamLineIo = class {
141
141
  this.input.destroy();
142
142
  }
143
143
  };
144
+ var STDIO_WRITE_BACKLOG_BYTES = 32 * 1024 * 1024;
144
145
 
145
146
  // src/errors.ts
146
147
  var GP_E_ENGINE_UNREACHABLE = "GP_E_ENGINE_UNREACHABLE";
@@ -149,6 +150,7 @@ var GP_E_NO_EVIDENCE = "GP_E_NO_EVIDENCE";
149
150
  var GP_E_INTERNAL = "GP_E_INTERNAL";
150
151
  var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
151
152
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
153
+ var GP_E_REGISTRY_DRIFT = "GP_E_REGISTRY_DRIFT";
152
154
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
153
155
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
154
156
  var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
@@ -219,6 +221,24 @@ function isReplayUnsafeMethod(method) {
219
221
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
220
222
  }
221
223
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
224
+ function describeCapabilityDrift(advertised, forwarded) {
225
+ if (advertised === null) {
226
+ return "engine self-report carries no capabilities list, so this shell cannot say which daemon methods it measured; every tool result in this session is unverified against the daemon's own method table (an engine older than the capability list, or a hello that was cut short)";
227
+ }
228
+ const missing = forwarded.filter((method) => !advertised.includes(method));
229
+ const unexposed = advertised.filter((method) => !forwarded.includes(method));
230
+ if (missing.length === 0 && unexposed.length === 0) {
231
+ return null;
232
+ }
233
+ const parts = [];
234
+ if (missing.length > 0) {
235
+ parts.push(`this shell forwards ${missing.join(", ")} but the daemon's hello does not list it/them \u2014 that call will be answered methodNotFound, so the tool surface and the engine method table have drifted`);
236
+ }
237
+ if (unexposed.length > 0) {
238
+ parts.push(`the daemon offers ${unexposed.join(", ")} which this shell does not expose as a tool \u2014 not an error, but the agent cannot reach it from MCP`);
239
+ }
240
+ return `capability reconciliation: ${parts.join("; ")}`;
241
+ }
222
242
  var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
223
243
  function engineDeadlineMs(method, params) {
224
244
  if (method === "restore") {
@@ -303,12 +323,13 @@ function slowEngineRemedy(method, surface = "mcp") {
303
323
  var LATE_REPLY_RETENTION = 16;
304
324
  var LOG_BODY_CHARS = 64 * 1024;
305
325
  var EngineJsonRpcClient = class {
306
- constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp") {
326
+ constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp", expectedDaemonMethods = []) {
307
327
  this.io = io;
308
328
  this.timeoutMs = timeoutMs;
309
329
  this.identity = identity;
310
330
  this.callerCeilingMs = callerCeilingMs;
311
331
  this.surface = surface;
332
+ this.expectedDaemonMethods = expectedDaemonMethods;
312
333
  this.io.onMessage((line) => this.handleMessage(line));
313
334
  this.io.onError((error) => {
314
335
  if (error instanceof OversizeFrameError) {
@@ -333,6 +354,7 @@ var EngineJsonRpcClient = class {
333
354
  identity;
334
355
  callerCeilingMs;
335
356
  surface;
357
+ expectedDaemonMethods;
336
358
  /**
337
359
  * Every mutable piece of this client's state, in one object that is *never*
338
360
  * reassigned through `this`.
@@ -749,6 +771,13 @@ var EngineJsonRpcClient = class {
749
771
  if (version !== null && wantVersion !== void 0 && version !== wantVersion) {
750
772
  this.report(`version mismatch: engine self-reports '${version}', this MCP shell ships '${wantVersion}' (single version line) \u2014 one side is stale`);
751
773
  }
774
+ if (this.expectedDaemonMethods.length > 0) {
775
+ const advertised = Array.isArray(report.capabilities) ? report.capabilities.filter((item) => typeof item === "string") : null;
776
+ const drift = describeCapabilityDrift(advertised, this.expectedDaemonMethods);
777
+ if (drift !== null) {
778
+ this.report(drift);
779
+ }
780
+ }
752
781
  }
753
782
  };
754
783
  function attribute(error, entry, inferred = true) {
@@ -843,7 +872,8 @@ function unixSocketEngineClient(socketPath, options = {}) {
843
872
  void 0,
844
873
  options.identity ?? null,
845
874
  options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS,
846
- options.surface ?? "mcp"
875
+ options.surface ?? "mcp",
876
+ options.expectedDaemonMethods ?? []
847
877
  );
848
878
  }
849
879
 
@@ -1559,7 +1589,7 @@ function registryDriftReason(filePath, openedAs, now) {
1559
1589
  function assertNoConcurrentWrite(filePath, openedAs) {
1560
1590
  const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1561
1591
  if (reason !== null) {
1562
- throw new ProjectRegistryError("registry-changed-underneath", reason);
1592
+ throw new ProjectRegistryError(GP_E_REGISTRY_DRIFT, reason);
1563
1593
  }
1564
1594
  }
1565
1595
  function saveProjects(filePath, entries) {
@@ -1877,7 +1907,7 @@ var ProjectRegistryError = class extends Error {
1877
1907
  var PID_INT32_MAX = 2147483647;
1878
1908
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
1879
1909
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
1880
- var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
1910
+ var OptionalRoleSchema = z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional();
1881
1911
  var OptionalBundleIdSchema = z5.string().max(512).optional();
1882
1912
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1883
1913
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
@@ -2259,6 +2289,7 @@ var TOOL_SPECS = [
2259
2289
  execute: projectGetTool
2260
2290
  }
2261
2291
  ];
2292
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2262
2293
  var TOOL_BY_NAME = new Map(
2263
2294
  TOOL_SPECS.map((spec) => [spec.name, spec])
2264
2295
  );
@@ -2578,6 +2609,9 @@ function projectErrorRemedy(code) {
2578
2609
  if (code === GP_E_NOT_FOUND) {
2579
2610
  return "check the projectId; use gp_project_list to view available projects";
2580
2611
  }
2612
+ if (code === GP_E_REGISTRY_DRIFT) {
2613
+ return "the projects file changed between the read that produced this reply and the write it attempted, so nothing was written: re-run the same gp_project_set \u2014 what this refusal wants is a second read, not different arguments. If it repeats, another writer owns that file (the daemon's maintenance CLI and the settings panel both rewrite it): read the table with `glasspaned --list-projects`, and remember a removal only sticks after the daemon restarts. `GLASSPANE_PROJECTS_FORCE_OVERWRITE=1` moves a file aside before writing, and nothing is overwritten without it.";
2614
+ }
2581
2615
  if (code === GP_E_NO_USER_RECORD) {
2582
2616
  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";
2583
2617
  }
@@ -3423,6 +3457,8 @@ function httpStatusForEngineCode(code) {
3423
3457
  return 503;
3424
3458
  case GP_E_ENGINE_TIMEOUT:
3425
3459
  return 504;
3460
+ case GP_E_REGISTRY_DRIFT:
3461
+ return 503;
3426
3462
  case GP_E_PAYLOAD_TOO_LARGE:
3427
3463
  return 502;
3428
3464
  case GP_E_INTERNAL:
package/dist/index.js CHANGED
@@ -495,6 +495,30 @@ function readUpdateState(env = process.env, stateRootOverride) {
495
495
  summary: `update state: no ${stateFile} \u2014 auto-update has never run on this machine, so nothing here says the installed GlassPane is current; run \`node updater/cli.js check\` (or use the settings panel) to find out`
496
496
  };
497
497
  }
498
+ let stat;
499
+ try {
500
+ stat = fs.statSync(stateFile);
501
+ } catch (error) {
502
+ return {
503
+ kind: "unreadable",
504
+ stateFile,
505
+ summary: `update state: ${stateFile} exists but could not be stat-ed (${String(error)}); freshness is unknown, which is not the same as up to date`
506
+ };
507
+ }
508
+ if (!stat.isFile()) {
509
+ return {
510
+ kind: "unreadable",
511
+ stateFile,
512
+ summary: `update state: ${stateFile} is not a regular file (mode ${(stat.mode & 511).toString(8)}), so nothing was parsed from it; freshness is unknown, which is not the same as up to date`
513
+ };
514
+ }
515
+ if ((stat.mode & 63) !== 0) {
516
+ return {
517
+ kind: "unreadable",
518
+ stateFile,
519
+ summary: `update state: ${stateFile} is mode ${(stat.mode & 511).toString(8)} \u2014 group/world readable, so this reader will not present it as the owner-only document it claims to be. Run \`chmod 600 ${stateFile}\` (the updater writes it 0600) and read again; freshness stays unknown, which is not the same as up to date`
520
+ };
521
+ }
498
522
  let text;
499
523
  try {
500
524
  text = fs.readFileSync(stateFile, "utf8");
@@ -729,12 +753,14 @@ var OrderedReplyQueue = class {
729
753
  return this.tail;
730
754
  }
731
755
  };
756
+ var STDIO_WRITE_BACKLOG_BYTES = 32 * 1024 * 1024;
732
757
  var DrainAwareWriter = class {
733
- constructor(sink, report, drainWaitMs, onDead) {
758
+ constructor(sink, report, drainWaitMs, onDead, backlogLimitBytes = STDIO_WRITE_BACKLOG_BYTES) {
734
759
  this.sink = sink;
735
760
  this.report = report;
736
761
  this.drainWaitMs = drainWaitMs;
737
762
  this.onDead = onDead;
763
+ this.backlogLimitBytes = backlogLimitBytes;
738
764
  sink.on("error", (error) => {
739
765
  if (this.dead) {
740
766
  return;
@@ -748,15 +774,27 @@ var DrainAwareWriter = class {
748
774
  report;
749
775
  drainWaitMs;
750
776
  onDead;
777
+ backlogLimitBytes;
751
778
  dead = null;
779
+ dropped = 0;
752
780
  get failed() {
753
781
  return this.dead !== null;
754
782
  }
783
+ /** How many replies this writer refused to queue. `gp_diagnose` reads it: a
784
+ * silently-shrinking answer is the failure mode this ceiling replaces. */
785
+ get droppedFrames() {
786
+ return this.dropped;
787
+ }
755
788
  /** @param text already framed, including its trailing newline. */
756
789
  async write(text) {
757
790
  if (this.failed) {
758
791
  return false;
759
792
  }
793
+ if (this.sink.writableLength >= this.backlogLimitBytes) {
794
+ this.dropped += 1;
795
+ this.report(`reply dropped, not queued: stdout already holds ${this.sink.writableLength} bytes past the ${this.backlogLimitBytes} ceiling \u2014 the reader on the other end stopped consuming, so this frame (and ${this.dropped - 1} earlier one(s)) will not be delivered`);
796
+ return false;
797
+ }
760
798
  let flowing = true;
761
799
  try {
762
800
  flowing = this.sink.write(text);
@@ -809,6 +847,7 @@ var GP_E_NO_EVIDENCE = "GP_E_NO_EVIDENCE";
809
847
  var GP_E_INTERNAL = "GP_E_INTERNAL";
810
848
  var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
811
849
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
850
+ var GP_E_REGISTRY_DRIFT = "GP_E_REGISTRY_DRIFT";
812
851
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
813
852
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
814
853
  var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
@@ -883,6 +922,24 @@ function isReplayUnsafeMethod(method) {
883
922
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
884
923
  }
885
924
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
925
+ function describeCapabilityDrift(advertised, forwarded) {
926
+ if (advertised === null) {
927
+ return "engine self-report carries no capabilities list, so this shell cannot say which daemon methods it measured; every tool result in this session is unverified against the daemon's own method table (an engine older than the capability list, or a hello that was cut short)";
928
+ }
929
+ const missing = forwarded.filter((method) => !advertised.includes(method));
930
+ const unexposed = advertised.filter((method) => !forwarded.includes(method));
931
+ if (missing.length === 0 && unexposed.length === 0) {
932
+ return null;
933
+ }
934
+ const parts = [];
935
+ if (missing.length > 0) {
936
+ parts.push(`this shell forwards ${missing.join(", ")} but the daemon's hello does not list it/them \u2014 that call will be answered methodNotFound, so the tool surface and the engine method table have drifted`);
937
+ }
938
+ if (unexposed.length > 0) {
939
+ parts.push(`the daemon offers ${unexposed.join(", ")} which this shell does not expose as a tool \u2014 not an error, but the agent cannot reach it from MCP`);
940
+ }
941
+ return `capability reconciliation: ${parts.join("; ")}`;
942
+ }
886
943
  var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
887
944
  function engineDeadlineMs(method, params) {
888
945
  if (method === "restore") {
@@ -967,12 +1024,13 @@ function slowEngineRemedy(method, surface = "mcp") {
967
1024
  var LATE_REPLY_RETENTION = 16;
968
1025
  var LOG_BODY_CHARS = 64 * 1024;
969
1026
  var EngineJsonRpcClient = class {
970
- constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp") {
1027
+ constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp", expectedDaemonMethods = []) {
971
1028
  this.io = io;
972
1029
  this.timeoutMs = timeoutMs;
973
1030
  this.identity = identity;
974
1031
  this.callerCeilingMs = callerCeilingMs;
975
1032
  this.surface = surface;
1033
+ this.expectedDaemonMethods = expectedDaemonMethods;
976
1034
  this.io.onMessage((line) => this.handleMessage(line));
977
1035
  this.io.onError((error) => {
978
1036
  if (error instanceof OversizeFrameError) {
@@ -997,6 +1055,7 @@ var EngineJsonRpcClient = class {
997
1055
  identity;
998
1056
  callerCeilingMs;
999
1057
  surface;
1058
+ expectedDaemonMethods;
1000
1059
  /**
1001
1060
  * Every mutable piece of this client's state, in one object that is *never*
1002
1061
  * reassigned through `this`.
@@ -1413,6 +1472,13 @@ var EngineJsonRpcClient = class {
1413
1472
  if (version !== null && wantVersion !== void 0 && version !== wantVersion) {
1414
1473
  this.report(`version mismatch: engine self-reports '${version}', this MCP shell ships '${wantVersion}' (single version line) \u2014 one side is stale`);
1415
1474
  }
1475
+ if (this.expectedDaemonMethods.length > 0) {
1476
+ const advertised = Array.isArray(report.capabilities) ? report.capabilities.filter((item) => typeof item === "string") : null;
1477
+ const drift = describeCapabilityDrift(advertised, this.expectedDaemonMethods);
1478
+ if (drift !== null) {
1479
+ this.report(drift);
1480
+ }
1481
+ }
1416
1482
  }
1417
1483
  };
1418
1484
  function attribute(error, entry, inferred = true) {
@@ -1507,7 +1573,8 @@ function unixSocketEngineClient(socketPath, options = {}) {
1507
1573
  void 0,
1508
1574
  options.identity ?? null,
1509
1575
  options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS,
1510
- options.surface ?? "mcp"
1576
+ options.surface ?? "mcp",
1577
+ options.expectedDaemonMethods ?? []
1511
1578
  );
1512
1579
  }
1513
1580
 
@@ -1945,7 +2012,7 @@ function registryDriftReason(filePath, openedAs, now) {
1945
2012
  function assertNoConcurrentWrite(filePath, openedAs) {
1946
2013
  const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1947
2014
  if (reason !== null) {
1948
- throw new ProjectRegistryError("registry-changed-underneath", reason);
2015
+ throw new ProjectRegistryError(GP_E_REGISTRY_DRIFT, reason);
1949
2016
  }
1950
2017
  }
1951
2018
  function saveProjects(filePath, entries) {
@@ -2263,7 +2330,7 @@ var ProjectRegistryError = class extends Error {
2263
2330
  var PID_INT32_MAX = 2147483647;
2264
2331
  var OptionalPidSchema = z5.number().int().min(1).max(PID_INT32_MAX).optional();
2265
2332
  var OptionalDepthSchema = z5.number().int().min(1).max(10).optional();
2266
- var OptionalRoleSchema = z5.string().max(SELECTOR_MAX_LENGTH).optional();
2333
+ var OptionalRoleSchema = z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional();
2267
2334
  var OptionalBundleIdSchema = z5.string().max(512).optional();
2268
2335
  var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
2269
2336
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
@@ -2645,6 +2712,7 @@ var TOOL_SPECS = [
2645
2712
  execute: projectGetTool
2646
2713
  }
2647
2714
  ];
2715
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2648
2716
  var TOOL_BY_NAME = new Map(
2649
2717
  TOOL_SPECS.map((spec) => [spec.name, spec])
2650
2718
  );
@@ -3209,6 +3277,9 @@ function projectErrorRemedy(code) {
3209
3277
  if (code === GP_E_NOT_FOUND) {
3210
3278
  return "check the projectId; use gp_project_list to view available projects";
3211
3279
  }
3280
+ if (code === GP_E_REGISTRY_DRIFT) {
3281
+ return "the projects file changed between the read that produced this reply and the write it attempted, so nothing was written: re-run the same gp_project_set \u2014 what this refusal wants is a second read, not different arguments. If it repeats, another writer owns that file (the daemon's maintenance CLI and the settings panel both rewrite it): read the table with `glasspaned --list-projects`, and remember a removal only sticks after the daemon restarts. `GLASSPANE_PROJECTS_FORCE_OVERWRITE=1` moves a file aside before writing, and nothing is overwritten without it.";
3282
+ }
3212
3283
  if (code === GP_E_NO_USER_RECORD) {
3213
3284
  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";
3214
3285
  }
@@ -3722,7 +3793,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3722
3793
  "2025-03-26",
3723
3794
  MCP_PROTOCOL_VERSION
3724
3795
  ];
3725
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.12.2" };
3796
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.13.0" };
3726
3797
  var PARSE_ERROR = -32700;
3727
3798
  var INVALID_REQUEST = -32600;
3728
3799
  var METHOD_NOT_FOUND = -32601;
@@ -4016,7 +4087,10 @@ function main() {
4016
4087
  let engine;
4017
4088
  try {
4018
4089
  engine = unixSocketEngineClient(options.socketPath, {
4019
- identity: { version: SERVER_INFO.version }
4090
+ identity: { version: SERVER_INFO.version },
4091
+ // `hello.capabilities` 的读者在这一层接线:清单由 daemon 派生、由这里与 TOOL_SPECS
4092
+ // 对账,两边谁漂了都会有人说,而不是各自安静地活着。
4093
+ expectedDaemonMethods: DAEMON_FORWARDED_METHODS
4020
4094
  });
4021
4095
  } catch (error) {
4022
4096
  writeDescriptor(
package/dist/io.d.ts CHANGED
@@ -131,6 +131,15 @@ export declare class OrderedReplyQueue {
131
131
  /** Resolves once everything queued so far has been attempted. */
132
132
  drained(): Promise<void>;
133
133
  }
134
+ /**
135
+ * 回包在 stdout 队列里允许的积压上限。镜像方向的同一件事早就有闸(`engine-client.ts` 的
136
+ * `ENGINE_WRITE_BACKLOG_BYTES` + 自己的错误码),stdio 这一侧却只说过"有 DrainAwareWriter
137
+ * 管背压"——串行化不等于**有界**:30s 的 drain 看门狗一响,后面的 `write()` 照样继续往
138
+ * Node 的队列里塞,一个不读了客户端就能把 `gp_recent_reports`(20 × 4 MiB,且这层没有
139
+ * 出站帧长上限)变成无界内存。到线就丢这一帧、点名丢了谁、说清为什么,比让进程被 OOM
140
+ * 杀掉后再也答不出第 21 条更好。
141
+ */
142
+ export declare const STDIO_WRITE_BACKLOG_BYTES: number;
134
143
  /**
135
144
  * Writes with backpressure honoured and a dead pipe survived: an ignored
136
145
  * `write()` return lets replies pile up in memory, and an unlistened `error`
@@ -141,9 +150,14 @@ export declare class DrainAwareWriter {
141
150
  private readonly report;
142
151
  private readonly drainWaitMs;
143
152
  private readonly onDead;
153
+ private readonly backlogLimitBytes;
144
154
  private dead;
145
- constructor(sink: Writable, report: (note: string) => void, drainWaitMs: number, onDead: (error: Error) => void);
155
+ private dropped;
156
+ constructor(sink: Writable, report: (note: string) => void, drainWaitMs: number, onDead: (error: Error) => void, backlogLimitBytes?: number);
146
157
  get failed(): boolean;
158
+ /** How many replies this writer refused to queue. `gp_diagnose` reads it: a
159
+ * silently-shrinking answer is the failure mode this ceiling replaces. */
160
+ get droppedFrames(): number;
147
161
  /** @param text already framed, including its trailing newline. */
148
162
  write(text: string): Promise<boolean>;
149
163
  private waitForDrain;
package/dist/tools.d.ts CHANGED
@@ -288,6 +288,14 @@ export interface ToolResult {
288
288
  isError: boolean;
289
289
  }
290
290
  export declare const TOOL_SPECS: readonly ToolSpec[];
291
+ /**
292
+ * The daemon methods this shell forwards, derived from {@link TOOL_SPECS} rather
293
+ * than written out. This is the list `hello.capabilities` is reconciled against
294
+ * (`engine-client.describeCapabilityDrift`): a hand-copied second truth is the
295
+ * exact drift the reader exists to catch, and the gateway already derives its own
296
+ * forwardable set from `execute === undefined` for the same reason.
297
+ */
298
+ export declare const DAEMON_FORWARDED_METHODS: readonly string[];
291
299
  export declare const TOOL_BY_NAME: ReadonlyMap<string, ToolSpec>;
292
300
  /** Does this tool read or write `EvidenceAuditSession`? */
293
301
  export declare function trailScopedTool(spec: ToolSpec): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.12.2",
3
+ "version": "1.13.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",