glasspane-mcp 1.3.1 → 1.4.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.3.1";
29
+ readonly version: "1.4.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -139,6 +139,28 @@ export declare const RESTORE_BASE_DEADLINE_MS = 30000;
139
139
  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
+ /**
143
+ * Methods whose re-issue writes daemon state the *caller* depends on, even though
144
+ * replaying them is not unsafe in the sense {@link REPLAY_UNSAFE_REASONS} means
145
+ * (no operation of the user's is performed a second time). They are the other half
146
+ * of the answer to "may this be narrowed by sending it again?", and they are kept
147
+ * apart from the set above because `test/probe-liveness.test.mjs` pins that set's
148
+ * membership as the no-replay judgement.
149
+ *
150
+ * `snapshot` is here because of a ring, not of a click: `storeSnapshot`
151
+ * (`engine/Sources/GlassPaneEngine/EngineCore.swift:2070`) appends the new
152
+ * baseline and then trims oldest-first to `EngineCore.snapshotHistoryLimit` (the
153
+ * same file, line 25 — 8 entries). A `gp_snapshot` whose caller timed out may
154
+ * therefore have been carried out already, and every re-send — "with a smaller
155
+ * maxDepth", which is what the tool layer used to say — adds another entry that can
156
+ * evict the baseline the caller's *own earlier* snapshot returned, the one
157
+ * `gp_restore {snapshotId}` needs later. Evicting a caller's evidence to fetch a
158
+ * cheaper copy of it is not narrowing.
159
+ *
160
+ * `attach` writes state too, and is already a replay-unsafe method; `tools.ts`
161
+ * unions the two lists rather than repeating names here.
162
+ */
163
+ export declare const STATE_CHANGING_ON_RETRY_METHOD_NAMES: readonly string[];
142
164
  /** Deadline for one request; `restore` scales with its replayed step count. */
143
165
  export declare function engineDeadlineMs(method: string, params?: Record<string, unknown>): number;
144
166
  /** When the caller gets its answer for `method`: its deadline, capped. */
@@ -366,6 +388,12 @@ export declare class EngineJsonRpcClient {
366
388
  * Oldest request this client still tracks, settled ones included: that is the
367
389
  * request the daemon's arrival-ordered answer belongs to. `Map` iteration is
368
390
  * insertion order, which is the order the requests went out.
391
+ *
392
+ * An entry whose id-less answer has already been taken (`arrivalOrderConsumed`)
393
+ * is passed over for *this* purpose while staying in the map for the echoed-id
394
+ * lookup: the guess has been spent once and may not be spent again on the next
395
+ * id-less frame, and the request it was spent on is still the request whose real
396
+ * reply can arrive with its id (finding 中-4).
369
397
  */
370
398
  private oldestTracked;
371
399
  /**
@@ -219,6 +219,7 @@ function isReplayUnsafeMethod(method) {
219
219
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
220
220
  }
221
221
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
222
+ var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
222
223
  function engineDeadlineMs(method, params) {
223
224
  if (method === "restore") {
224
225
  const steps = params?.steps;
@@ -275,6 +276,10 @@ var NARROWING_AUTHORITY = {
275
276
  mcp: "the gp_* tool that took this call publishes its own parameters, and their floors, in the schema shown beside that tool \u2014 read it there, not here",
276
277
  http: "this gateway publishes no parameter listing of its own, so the floors a body was accepted against are in the request contract this call was copied from"
277
278
  };
279
+ function clientClosingRemedy(surface) {
280
+ const readRoute = surface === "mcp" ? "gp_recent_reports lists the operations this session recorded and gp_last_evidence {operationId} returns one in full" : "this gateway keeps no operation trail of its own, so the stderr line this reply is written to is the record";
281
+ return `this shell's own engine client is ending, which measures nothing about the daemon: nothing here authorises touching the daemon's lifecycle, and no restart is authorised by this answer. What is unknown stays unknown: whether the daemon finished this request cannot be told from a client that stopped asking. The reply may still land, under the bound this shell already states \u2014 ${lateReplyRoute(surface)} \u2014 and this shutdown is one of the endings that bound names: the server log line written at this moment lists the requests this client stopped tracking. Read what happened rather than performing it a second time: ${readRoute}. Ask through a server process that still has a live engine client.`;
282
+ }
278
283
  function reIssueGuidance(surface) {
279
284
  return `wait for the outstanding reply rather than sending this request again: the daemon answers one request at a time, so a second copy of it now only queues behind the one still running and answers nothing sooner, and if the first did take effect the copy performs it a second time. What is worth narrowing here, and whether it can still be narrowed at the values this request already sent, is a fact about a schema, and this layer holds no schema for any request: ${NARROWING_AUTHORITY[surface]}. Re-send only after reading that, and only if the request is one whose repetition changes nothing`;
280
285
  }
@@ -430,8 +435,8 @@ var EngineJsonRpcClient = class {
430
435
  this.state.closed = true;
431
436
  this.teardown(new EngineCallError(
432
437
  GP_E_ENGINE_UNREACHABLE,
433
- "engine client closed",
434
- daemonUnreachableRemedy()
438
+ "engine client closed: this shell's own client is ending, so whether the daemon finished this request is not known here and this client can no longer attribute the reply that may still land",
439
+ clientClosingRemedy(this.surface)
435
440
  ));
436
441
  this.io.close();
437
442
  }
@@ -480,7 +485,11 @@ var EngineJsonRpcClient = class {
480
485
  if (typeof id === "number") {
481
486
  const entry = this.pending.get(id);
482
487
  if (!entry) {
483
- this.report(`engine sent a frame for request id ${id}, which this client never issued (or already gave up on): ${truncate(line)}`);
488
+ const tracked = [...this.pending.values()];
489
+ const held = tracked.length === 0 ? "nothing is tracked now" : `this client still tracks ${tracked.length} request(s): ` + tracked.map((kept) => "'" + kept.method + "' id " + kept.id + (kept.settled ? " (caller already answered)" : "")).join(", ");
490
+ this.report(
491
+ `engine sent a frame for request id ${id}, which this client never issued (or already gave up on); ` + held + ": " + truncate(line)
492
+ );
484
493
  return;
485
494
  }
486
495
  this.pending.delete(id);
@@ -495,8 +504,9 @@ var EngineJsonRpcClient = class {
495
504
  const target = this.oldestTracked();
496
505
  if (target) {
497
506
  if (target.settled) {
498
- this.pending.delete(target.id);
499
507
  this.reportLateReply(target, frame, true);
508
+ target.arrivalOrderConsumed = true;
509
+ this.retainForLateReply();
500
510
  return;
501
511
  }
502
512
  if (frame.error) {
@@ -557,9 +567,20 @@ var EngineJsonRpcClient = class {
557
567
  * Oldest request this client still tracks, settled ones included: that is the
558
568
  * request the daemon's arrival-ordered answer belongs to. `Map` iteration is
559
569
  * insertion order, which is the order the requests went out.
570
+ *
571
+ * An entry whose id-less answer has already been taken (`arrivalOrderConsumed`)
572
+ * is passed over for *this* purpose while staying in the map for the echoed-id
573
+ * lookup: the guess has been spent once and may not be spent again on the next
574
+ * id-less frame, and the request it was spent on is still the request whose real
575
+ * reply can arrive with its id (finding 中-4).
560
576
  */
561
577
  oldestTracked() {
562
- return this.pending.values().next().value;
578
+ for (const entry of this.pending.values()) {
579
+ if (!entry.arrivalOrderConsumed) {
580
+ return entry;
581
+ }
582
+ }
583
+ return void 0;
563
584
  }
564
585
  /**
565
586
  * Route the reply that lands after its caller was already answered. `inferred`
@@ -1041,8 +1062,8 @@ var LEGACY_EVIDENCE_SCHEMA_VERSION = "glasspane.evidence/0.1-draft";
1041
1062
  var AttributionLevelSchema = z.enum(["soft", "strong", "weak"]);
1042
1063
  var CircuitBreakerLevelSchema = z.number().int().min(0).max(3);
1043
1064
  var SelectorSchema = z.strictObject({
1044
- role: z.string().max(SELECTOR_MAX_LENGTH),
1045
- title: z.string().max(SELECTOR_MAX_LENGTH).optional(),
1065
+ role: z.string().min(1).max(SELECTOR_MAX_LENGTH),
1066
+ title: z.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
1046
1067
  identifier: z.string().max(SELECTOR_MAX_LENGTH).optional()
1047
1068
  });
1048
1069
  var ActionSchema = z.enum([
@@ -1794,14 +1815,9 @@ var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1794
1815
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
1795
1816
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
1796
1817
  var ExpectSchema = z5.union([z5.string().max(512), z5.boolean()]);
1797
- var ShellSelectorSchema = z5.strictObject({
1798
- role: z5.string().min(1).max(SELECTOR_MAX_LENGTH),
1799
- title: z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
1800
- identifier: z5.string().max(SELECTOR_MAX_LENGTH).optional()
1801
- });
1802
1818
  var SnapshotIdSchema = z5.string().regex(/^snap_[0-9A-HJKMNP-TV-Z]{26}$/);
1803
1819
  var ActStepSchema = z5.strictObject({
1804
- selector: ShellSelectorSchema,
1820
+ selector: SelectorSchema,
1805
1821
  action: ActionSchema
1806
1822
  });
1807
1823
  var RestoreStepsSchema = z5.array(ActStepSchema).min(1).max(64).optional();
@@ -1841,12 +1857,12 @@ var AuditUiArgs = z5.strictObject({
1841
1857
  minHitTargetPt: OptionalHitTargetSchema
1842
1858
  });
1843
1859
  var ActArgs = z5.strictObject({
1844
- selector: ShellSelectorSchema,
1860
+ selector: SelectorSchema,
1845
1861
  action: ActionSchema,
1846
1862
  degrade: z5.boolean().optional()
1847
1863
  });
1848
1864
  var AssertElementArgs = z5.strictObject({
1849
- selector: ShellSelectorSchema,
1865
+ selector: SelectorSchema,
1850
1866
  property: AssertionPropertySchema,
1851
1867
  expected: ExpectSchema
1852
1868
  });
@@ -1890,9 +1906,11 @@ var selectorJsonSchema = {
1890
1906
  type: "object",
1891
1907
  additionalProperties: false,
1892
1908
  properties: {
1893
- // `minLength` here is the advertised half of {@link ShellSelectorSchema};
1909
+ // `minLength` here is the advertised half of the kernel's `SelectorSchema`;
1894
1910
  // `test/tools.test.mjs` compares the two against each other and against the
1895
- // daemon's own rule, so neither side can be tightened or loosened alone.
1911
+ // daemon's own rule, and `test/consumer-consistency.test.mjs` compares both
1912
+ // against the published `kernel/schemas/evidence-pack.schema.json`, so no
1913
+ // side can be tightened or loosened alone.
1896
1914
  role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
1897
1915
  title: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
1898
1916
  identifier: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
@@ -2176,6 +2194,25 @@ var TOOL_SPECS = [
2176
2194
  var TOOL_BY_NAME = new Map(
2177
2195
  TOOL_SPECS.map((spec) => [spec.name, spec])
2178
2196
  );
2197
+ var TARGET_SELECTING_KNOBS = {
2198
+ gp_act: {
2199
+ param: "selector",
2200
+ picks: "which control the action is performed on",
2201
+ substitute: "a different control takes the press, and the user's screen changes in a way no one asked for"
2202
+ },
2203
+ gp_assert_element: {
2204
+ param: "selector",
2205
+ picks: "which element the claim is about",
2206
+ substitute: "the answer then describes a different element, while this session's trail still files it as the verdict on the claim you first made"
2207
+ }
2208
+ };
2209
+ function targetSelectingKnob(specName, paramName) {
2210
+ if (!Object.prototype.hasOwnProperty.call(TARGET_SELECTING_KNOBS, specName)) {
2211
+ return null;
2212
+ }
2213
+ const entry = TARGET_SELECTING_KNOBS[specName];
2214
+ return entry !== void 0 && entry.param === paramName ? { picks: entry.picks, substitute: entry.substitute } : null;
2215
+ }
2179
2216
  function narrowingKnobsFor(spec, params) {
2180
2217
  const properties = spec.inputSchema.properties ?? {};
2181
2218
  const knobs = [];
@@ -2190,11 +2227,21 @@ function narrowingKnobsFor(spec, params) {
2190
2227
  } else if (name === "limit") {
2191
2228
  knobs.push({ name, kind: "limit", minimum: schema.minimum, maximum: schema.maximum, sent });
2192
2229
  } else if (name === "selector" || name === "role" || name === "title") {
2193
- knobs.push({ name, kind: "selector", sent: void 0 });
2230
+ const target = targetSelectingKnob(spec.name, name);
2231
+ knobs.push(target === null ? { name, kind: "selector", sent: void 0 } : { name, kind: "target", sent: void 0, picks: target.picks, substitute: target.substitute });
2194
2232
  }
2195
2233
  }
2196
2234
  return knobs;
2197
2235
  }
2236
+ function replyShrinkingKnobs(knobs) {
2237
+ return knobs.filter((knob) => knob.kind !== "target");
2238
+ }
2239
+ function targetKnobs(knobs) {
2240
+ return knobs.filter((knob) => knob.kind === "target");
2241
+ }
2242
+ function describeTargetKnob(knob) {
2243
+ return `'${knob.name}' names ${knob.picks ?? "which element this operation is about"}, so changing it is not a smaller or cheaper request \u2014 it is a different operation, and ${knob.substitute ?? "the answer then describes something else"}`;
2244
+ }
2198
2245
  function atAdvertisedFloor(knob) {
2199
2246
  return knob.minimum !== void 0 && knob.sent !== void 0 && knob.sent <= knob.minimum;
2200
2247
  }
@@ -2208,6 +2255,8 @@ function knobSubject(kind) {
2208
2255
  return "the image";
2209
2256
  case "selector":
2210
2257
  return "the reply";
2258
+ case "target":
2259
+ return "the element the operation is about";
2211
2260
  }
2212
2261
  }
2213
2262
  function describeKnob(knob) {
@@ -2224,19 +2273,27 @@ function describeKnob(knob) {
2224
2273
  return knob.name === "selector" ? "a more specific selector (aim at one element)" : `a tighter ${knob.name} filter`;
2225
2274
  case "scale":
2226
2275
  return `a smaller scale${bounds}${knob.sent !== void 0 ? `, currently ${knob.sent}` : ""}`;
2276
+ case "target":
2277
+ return describeTargetKnob(knob);
2227
2278
  }
2228
2279
  }
2229
2280
  function oversizedReplyAdvice(spec, params) {
2230
2281
  const knobs = narrowingKnobsFor(spec, params);
2231
2282
  const atFloor = knobs.filter(atAdvertisedFloor);
2232
- const shrinkable = knobs.filter((knob) => !atAdvertisedFloor(knob));
2283
+ const target = targetKnobs(knobs);
2284
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
2233
2285
  const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
2234
- const floorNote = atFloor.length === 0 ? "" : `${atFloor.map(describeKnob).join("; ")}; `;
2286
+ const refused = [...atFloor.map(describeKnob), ...target.map(describeTargetKnob)];
2287
+ const floorNote = refused.length === 0 ? "" : `${refused.join("; ")}; `;
2235
2288
  if (shrinkable.length === 0) {
2236
- return knobs.length > 0 ? head + `${atFloor.map(describeKnob).join("; ")} \u2014 nothing left to narrow on this request: ask a smaller question (one element, one region) rather than retrying it unchanged; the connection is intact and the daemon keeps answering other requests` : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2289
+ return knobs.length > 0 ? head + floorNote + "nothing left to narrow on this request: " + (target.length > 0 ? "this request already carries one element and one region, so read the tree with gp_observe and, if the element you meant is another one, make that its own claim rather than treating this answer as a size problem" : "ask a smaller question (one element, one region) rather than retrying it unchanged") + "; the connection is intact and the daemon keeps answering other requests" : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2237
2290
  }
2238
2291
  return head + floorNote + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
2239
2292
  }
2293
+ var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
2294
+ ...REPLAY_UNSAFE_METHOD_NAMES,
2295
+ ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
2296
+ ]);
2240
2297
  async function readTrail(session, turn, limit) {
2241
2298
  await turn.acquire();
2242
2299
  try {
@@ -215,6 +215,7 @@ function isReplayUnsafeMethod(method) {
215
215
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
216
216
  }
217
217
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
218
+ var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
218
219
  function engineDeadlineMs(method, params) {
219
220
  if (method === "restore") {
220
221
  const steps = params?.steps;
@@ -271,6 +272,10 @@ var NARROWING_AUTHORITY = {
271
272
  mcp: "the gp_* tool that took this call publishes its own parameters, and their floors, in the schema shown beside that tool \u2014 read it there, not here",
272
273
  http: "this gateway publishes no parameter listing of its own, so the floors a body was accepted against are in the request contract this call was copied from"
273
274
  };
275
+ function clientClosingRemedy(surface) {
276
+ const readRoute = surface === "mcp" ? "gp_recent_reports lists the operations this session recorded and gp_last_evidence {operationId} returns one in full" : "this gateway keeps no operation trail of its own, so the stderr line this reply is written to is the record";
277
+ return `this shell's own engine client is ending, which measures nothing about the daemon: nothing here authorises touching the daemon's lifecycle, and no restart is authorised by this answer. What is unknown stays unknown: whether the daemon finished this request cannot be told from a client that stopped asking. The reply may still land, under the bound this shell already states \u2014 ${lateReplyRoute(surface)} \u2014 and this shutdown is one of the endings that bound names: the server log line written at this moment lists the requests this client stopped tracking. Read what happened rather than performing it a second time: ${readRoute}. Ask through a server process that still has a live engine client.`;
278
+ }
274
279
  function reIssueGuidance(surface) {
275
280
  return `wait for the outstanding reply rather than sending this request again: the daemon answers one request at a time, so a second copy of it now only queues behind the one still running and answers nothing sooner, and if the first did take effect the copy performs it a second time. What is worth narrowing here, and whether it can still be narrowed at the values this request already sent, is a fact about a schema, and this layer holds no schema for any request: ${NARROWING_AUTHORITY[surface]}. Re-send only after reading that, and only if the request is one whose repetition changes nothing`;
276
281
  }
@@ -426,8 +431,8 @@ var EngineJsonRpcClient = class {
426
431
  this.state.closed = true;
427
432
  this.teardown(new EngineCallError(
428
433
  GP_E_ENGINE_UNREACHABLE,
429
- "engine client closed",
430
- daemonUnreachableRemedy()
434
+ "engine client closed: this shell's own client is ending, so whether the daemon finished this request is not known here and this client can no longer attribute the reply that may still land",
435
+ clientClosingRemedy(this.surface)
431
436
  ));
432
437
  this.io.close();
433
438
  }
@@ -476,7 +481,11 @@ var EngineJsonRpcClient = class {
476
481
  if (typeof id === "number") {
477
482
  const entry = this.pending.get(id);
478
483
  if (!entry) {
479
- this.report(`engine sent a frame for request id ${id}, which this client never issued (or already gave up on): ${truncate(line)}`);
484
+ const tracked = [...this.pending.values()];
485
+ const held = tracked.length === 0 ? "nothing is tracked now" : `this client still tracks ${tracked.length} request(s): ` + tracked.map((kept) => "'" + kept.method + "' id " + kept.id + (kept.settled ? " (caller already answered)" : "")).join(", ");
486
+ this.report(
487
+ `engine sent a frame for request id ${id}, which this client never issued (or already gave up on); ` + held + ": " + truncate(line)
488
+ );
480
489
  return;
481
490
  }
482
491
  this.pending.delete(id);
@@ -491,8 +500,9 @@ var EngineJsonRpcClient = class {
491
500
  const target = this.oldestTracked();
492
501
  if (target) {
493
502
  if (target.settled) {
494
- this.pending.delete(target.id);
495
503
  this.reportLateReply(target, frame, true);
504
+ target.arrivalOrderConsumed = true;
505
+ this.retainForLateReply();
496
506
  return;
497
507
  }
498
508
  if (frame.error) {
@@ -553,9 +563,20 @@ var EngineJsonRpcClient = class {
553
563
  * Oldest request this client still tracks, settled ones included: that is the
554
564
  * request the daemon's arrival-ordered answer belongs to. `Map` iteration is
555
565
  * insertion order, which is the order the requests went out.
566
+ *
567
+ * An entry whose id-less answer has already been taken (`arrivalOrderConsumed`)
568
+ * is passed over for *this* purpose while staying in the map for the echoed-id
569
+ * lookup: the guess has been spent once and may not be spent again on the next
570
+ * id-less frame, and the request it was spent on is still the request whose real
571
+ * reply can arrive with its id (finding 中-4).
556
572
  */
557
573
  oldestTracked() {
558
- return this.pending.values().next().value;
574
+ for (const entry of this.pending.values()) {
575
+ if (!entry.arrivalOrderConsumed) {
576
+ return entry;
577
+ }
578
+ }
579
+ return void 0;
559
580
  }
560
581
  /**
561
582
  * Route the reply that lands after its caller was already answered. `inferred`
@@ -1031,8 +1052,8 @@ var LEGACY_EVIDENCE_SCHEMA_VERSION = "glasspane.evidence/0.1-draft";
1031
1052
  var AttributionLevelSchema = z.enum(["soft", "strong", "weak"]);
1032
1053
  var CircuitBreakerLevelSchema = z.number().int().min(0).max(3);
1033
1054
  var SelectorSchema = z.strictObject({
1034
- role: z.string().max(SELECTOR_MAX_LENGTH),
1035
- title: z.string().max(SELECTOR_MAX_LENGTH).optional(),
1055
+ role: z.string().min(1).max(SELECTOR_MAX_LENGTH),
1056
+ title: z.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
1036
1057
  identifier: z.string().max(SELECTOR_MAX_LENGTH).optional()
1037
1058
  });
1038
1059
  var ActionSchema = z.enum([
@@ -1784,14 +1805,9 @@ var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
1784
1805
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
1785
1806
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
1786
1807
  var ExpectSchema = z5.union([z5.string().max(512), z5.boolean()]);
1787
- var ShellSelectorSchema = z5.strictObject({
1788
- role: z5.string().min(1).max(SELECTOR_MAX_LENGTH),
1789
- title: z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
1790
- identifier: z5.string().max(SELECTOR_MAX_LENGTH).optional()
1791
- });
1792
1808
  var SnapshotIdSchema = z5.string().regex(/^snap_[0-9A-HJKMNP-TV-Z]{26}$/);
1793
1809
  var ActStepSchema = z5.strictObject({
1794
- selector: ShellSelectorSchema,
1810
+ selector: SelectorSchema,
1795
1811
  action: ActionSchema
1796
1812
  });
1797
1813
  var RestoreStepsSchema = z5.array(ActStepSchema).min(1).max(64).optional();
@@ -1831,12 +1847,12 @@ var AuditUiArgs = z5.strictObject({
1831
1847
  minHitTargetPt: OptionalHitTargetSchema
1832
1848
  });
1833
1849
  var ActArgs = z5.strictObject({
1834
- selector: ShellSelectorSchema,
1850
+ selector: SelectorSchema,
1835
1851
  action: ActionSchema,
1836
1852
  degrade: z5.boolean().optional()
1837
1853
  });
1838
1854
  var AssertElementArgs = z5.strictObject({
1839
- selector: ShellSelectorSchema,
1855
+ selector: SelectorSchema,
1840
1856
  property: AssertionPropertySchema,
1841
1857
  expected: ExpectSchema
1842
1858
  });
@@ -1880,9 +1896,11 @@ var selectorJsonSchema = {
1880
1896
  type: "object",
1881
1897
  additionalProperties: false,
1882
1898
  properties: {
1883
- // `minLength` here is the advertised half of {@link ShellSelectorSchema};
1899
+ // `minLength` here is the advertised half of the kernel's `SelectorSchema`;
1884
1900
  // `test/tools.test.mjs` compares the two against each other and against the
1885
- // daemon's own rule, so neither side can be tightened or loosened alone.
1901
+ // daemon's own rule, and `test/consumer-consistency.test.mjs` compares both
1902
+ // against the published `kernel/schemas/evidence-pack.schema.json`, so no
1903
+ // side can be tightened or loosened alone.
1886
1904
  role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
1887
1905
  title: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
1888
1906
  identifier: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
@@ -2166,6 +2184,25 @@ var TOOL_SPECS = [
2166
2184
  var TOOL_BY_NAME = new Map(
2167
2185
  TOOL_SPECS.map((spec) => [spec.name, spec])
2168
2186
  );
2187
+ var TARGET_SELECTING_KNOBS = {
2188
+ gp_act: {
2189
+ param: "selector",
2190
+ picks: "which control the action is performed on",
2191
+ substitute: "a different control takes the press, and the user's screen changes in a way no one asked for"
2192
+ },
2193
+ gp_assert_element: {
2194
+ param: "selector",
2195
+ picks: "which element the claim is about",
2196
+ substitute: "the answer then describes a different element, while this session's trail still files it as the verdict on the claim you first made"
2197
+ }
2198
+ };
2199
+ function targetSelectingKnob(specName, paramName) {
2200
+ if (!Object.prototype.hasOwnProperty.call(TARGET_SELECTING_KNOBS, specName)) {
2201
+ return null;
2202
+ }
2203
+ const entry = TARGET_SELECTING_KNOBS[specName];
2204
+ return entry !== void 0 && entry.param === paramName ? { picks: entry.picks, substitute: entry.substitute } : null;
2205
+ }
2169
2206
  function narrowingKnobsFor(spec, params) {
2170
2207
  const properties = spec.inputSchema.properties ?? {};
2171
2208
  const knobs = [];
@@ -2180,11 +2217,21 @@ function narrowingKnobsFor(spec, params) {
2180
2217
  } else if (name === "limit") {
2181
2218
  knobs.push({ name, kind: "limit", minimum: schema.minimum, maximum: schema.maximum, sent });
2182
2219
  } else if (name === "selector" || name === "role" || name === "title") {
2183
- knobs.push({ name, kind: "selector", sent: void 0 });
2220
+ const target = targetSelectingKnob(spec.name, name);
2221
+ knobs.push(target === null ? { name, kind: "selector", sent: void 0 } : { name, kind: "target", sent: void 0, picks: target.picks, substitute: target.substitute });
2184
2222
  }
2185
2223
  }
2186
2224
  return knobs;
2187
2225
  }
2226
+ function replyShrinkingKnobs(knobs) {
2227
+ return knobs.filter((knob) => knob.kind !== "target");
2228
+ }
2229
+ function targetKnobs(knobs) {
2230
+ return knobs.filter((knob) => knob.kind === "target");
2231
+ }
2232
+ function describeTargetKnob(knob) {
2233
+ return `'${knob.name}' names ${knob.picks ?? "which element this operation is about"}, so changing it is not a smaller or cheaper request \u2014 it is a different operation, and ${knob.substitute ?? "the answer then describes something else"}`;
2234
+ }
2188
2235
  function atAdvertisedFloor(knob) {
2189
2236
  return knob.minimum !== void 0 && knob.sent !== void 0 && knob.sent <= knob.minimum;
2190
2237
  }
@@ -2198,6 +2245,8 @@ function knobSubject(kind) {
2198
2245
  return "the image";
2199
2246
  case "selector":
2200
2247
  return "the reply";
2248
+ case "target":
2249
+ return "the element the operation is about";
2201
2250
  }
2202
2251
  }
2203
2252
  function describeKnob(knob) {
@@ -2214,19 +2263,27 @@ function describeKnob(knob) {
2214
2263
  return knob.name === "selector" ? "a more specific selector (aim at one element)" : `a tighter ${knob.name} filter`;
2215
2264
  case "scale":
2216
2265
  return `a smaller scale${bounds}${knob.sent !== void 0 ? `, currently ${knob.sent}` : ""}`;
2266
+ case "target":
2267
+ return describeTargetKnob(knob);
2217
2268
  }
2218
2269
  }
2219
2270
  function oversizedReplyAdvice(spec, params) {
2220
2271
  const knobs = narrowingKnobsFor(spec, params);
2221
2272
  const atFloor = knobs.filter(atAdvertisedFloor);
2222
- const shrinkable = knobs.filter((knob) => !atAdvertisedFloor(knob));
2273
+ const target = targetKnobs(knobs);
2274
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
2223
2275
  const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
2224
- const floorNote = atFloor.length === 0 ? "" : `${atFloor.map(describeKnob).join("; ")}; `;
2276
+ const refused = [...atFloor.map(describeKnob), ...target.map(describeTargetKnob)];
2277
+ const floorNote = refused.length === 0 ? "" : `${refused.join("; ")}; `;
2225
2278
  if (shrinkable.length === 0) {
2226
- return knobs.length > 0 ? head + `${atFloor.map(describeKnob).join("; ")} \u2014 nothing left to narrow on this request: ask a smaller question (one element, one region) rather than retrying it unchanged; the connection is intact and the daemon keeps answering other requests` : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2279
+ return knobs.length > 0 ? head + floorNote + "nothing left to narrow on this request: " + (target.length > 0 ? "this request already carries one element and one region, so read the tree with gp_observe and, if the element you meant is another one, make that its own claim rather than treating this answer as a size problem" : "ask a smaller question (one element, one region) rather than retrying it unchanged") + "; the connection is intact and the daemon keeps answering other requests" : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2227
2280
  }
2228
2281
  return head + floorNote + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
2229
2282
  }
2283
+ var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
2284
+ ...REPLAY_UNSAFE_METHOD_NAMES,
2285
+ ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
2286
+ ]);
2230
2287
  async function readTrail(session, turn, limit) {
2231
2288
  await turn.acquire();
2232
2289
  try {
package/dist/index.js CHANGED
@@ -46,8 +46,8 @@ var LEGACY_EVIDENCE_SCHEMA_VERSION = "glasspane.evidence/0.1-draft";
46
46
  var AttributionLevelSchema = z.enum(["soft", "strong", "weak"]);
47
47
  var CircuitBreakerLevelSchema = z.number().int().min(0).max(3);
48
48
  var SelectorSchema = z.strictObject({
49
- role: z.string().max(SELECTOR_MAX_LENGTH),
50
- title: z.string().max(SELECTOR_MAX_LENGTH).optional(),
49
+ role: z.string().min(1).max(SELECTOR_MAX_LENGTH),
50
+ title: z.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
51
51
  identifier: z.string().max(SELECTOR_MAX_LENGTH).optional()
52
52
  });
53
53
  var ActionSchema = z.enum([
@@ -265,6 +265,107 @@ function parseEvidencePackRead(input) {
265
265
  throw schemaError("evidence pack", result.error);
266
266
  }
267
267
 
268
+ // src/update-state.ts
269
+ import fs from "node:fs";
270
+ import os from "node:os";
271
+ import path from "node:path";
272
+ var STATE_DIR_ENV = "GLASSPANE_STATE_DIR";
273
+ function recordHome() {
274
+ try {
275
+ return os.userInfo().homedir;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+ function updateStateFile(env = process.env, stateRootOverride) {
281
+ const named2 = stateRootOverride ?? env[STATE_DIR_ENV];
282
+ if (typeof named2 === "string" && named2.trim() !== "") {
283
+ return path.join(path.resolve(named2), "update-state.json");
284
+ }
285
+ const home = recordHome();
286
+ if (home === null) {
287
+ return null;
288
+ }
289
+ return path.join(home, ".glasspane", "update-state.json");
290
+ }
291
+ var KNOWN_STATUSES = /* @__PURE__ */ new Set([
292
+ "up-to-date",
293
+ "available",
294
+ "staged",
295
+ "applied",
296
+ "deferred",
297
+ "needs-consent",
298
+ "check-overdue",
299
+ "rolled-back",
300
+ "check-failed",
301
+ "staged-build-failed",
302
+ "apply-failed",
303
+ "rollback-failed",
304
+ "disabled",
305
+ "installer-pointer-stale"
306
+ ]);
307
+ function readUpdateState(env = process.env, stateRootOverride) {
308
+ const stateFile = updateStateFile(env, stateRootOverride);
309
+ if (stateFile === null) {
310
+ return {
311
+ kind: "unresolvable",
312
+ stateFile: null,
313
+ summary: "update state: this process has no password-database record, so the state root cannot be named; nothing was read and no claim about GlassPane's freshness is being made"
314
+ };
315
+ }
316
+ if (!fs.existsSync(stateFile)) {
317
+ return {
318
+ kind: "absent",
319
+ stateFile,
320
+ 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`
321
+ };
322
+ }
323
+ let text;
324
+ try {
325
+ text = fs.readFileSync(stateFile, "utf8");
326
+ } catch (error) {
327
+ return {
328
+ kind: "unreadable",
329
+ stateFile,
330
+ summary: `update state: ${stateFile} exists but could not be read (${String(error)}); freshness is unknown, which is not the same as up to date`
331
+ };
332
+ }
333
+ let parsed;
334
+ try {
335
+ parsed = JSON.parse(text);
336
+ } catch (error) {
337
+ return {
338
+ kind: "unreadable",
339
+ stateFile,
340
+ summary: `update state: ${stateFile} is not valid JSON (${String(error)}); freshness is unknown, which is not the same as up to date`
341
+ };
342
+ }
343
+ const status = typeof parsed.status === "string" ? parsed.status : null;
344
+ if (status === null || !KNOWN_STATUSES.has(status)) {
345
+ return {
346
+ kind: "unreadable",
347
+ stateFile,
348
+ summary: `update state: ${stateFile} carries status ${JSON.stringify(parsed.status ?? null)}, which is not a status this shell knows; the updater and this shell disagree about the contract, so freshness is unknown`
349
+ };
350
+ }
351
+ const bits = [
352
+ `status=${status}`,
353
+ `current=${String(parsed.current ?? "unknown")}`,
354
+ `latest=${String(parsed.latest ?? "none-seen")}`,
355
+ `lastCheckAt=${String(parsed.updatedAt ?? "never")}`,
356
+ `autoUpdate=${parsed.disabled === true ? "off" : parsed.autoApply === true ? "on" : "on-by-default"}`
357
+ ];
358
+ const lastError = parsed.lastError;
359
+ if (lastError && typeof lastError.message === "string") {
360
+ bits.push(`lastError=${lastError.code ?? "no-code"}: ${lastError.message}`);
361
+ }
362
+ const staged = parsed.staged;
363
+ if (staged && typeof staged.version === "string") {
364
+ bits.push(`staged=${staged.version}${staged.digest ? ` (${String(staged.digest).slice(0, 12)}\u2026)` : ""}`);
365
+ }
366
+ return { kind: "read", stateFile, summary: `update state: ${bits.join("; ")}` };
367
+ }
368
+
268
369
  // src/io.ts
269
370
  var MAX_FRAME_BYTES = 4 * 1024 * 1024;
270
371
  var OVERSIZE_PREFIX_BYTES = 512;
@@ -497,9 +598,9 @@ var DrainAwareWriter = class {
497
598
 
498
599
  // src/engine-client.ts
499
600
  import net from "node:net";
500
- import os from "node:os";
501
- import path from "node:path";
502
- import fs from "node:fs";
601
+ import os2 from "node:os";
602
+ import path2 from "node:path";
603
+ import fs2 from "node:fs";
503
604
  import { fileURLToPath } from "node:url";
504
605
 
505
606
  // src/errors.ts
@@ -522,16 +623,16 @@ function formatToolErrorShape(error) {
522
623
 
523
624
  // src/engine-client.ts
524
625
  function daemonUnreachableRemedy() {
525
- const here = path.dirname(fileURLToPath(import.meta.url));
526
- const installerCli = path.resolve(here, "..", "..", "installer", "cli.js");
527
- if (fs.existsSync(installerCli)) {
626
+ const here = path2.dirname(fileURLToPath(import.meta.url));
627
+ const installerCli = path2.resolve(here, "..", "..", "installer", "cli.js");
628
+ if (fs2.existsSync(installerCli)) {
528
629
  return `run "node ${installerCli} --restore-launchd" (auto-restores and verifies the launchd-managed daemon), then retry`;
529
630
  }
530
631
  return "ensure the glasspane daemon is running (launchd job com.glasspane.daemon; in a repo checkout run `node installer/cli.js --restore-launchd`), then retry";
531
632
  }
532
633
  var ENGINE_SOCKET_ENV = "GLASSPANE_ENGINE_SOCK";
533
634
  function defaultSocketPath() {
534
- return process.env[ENGINE_SOCKET_ENV] ?? path.join(os.homedir(), ".glasspane", "engine.sock");
635
+ return process.env[ENGINE_SOCKET_ENV] ?? path2.join(os2.homedir(), ".glasspane", "engine.sock");
535
636
  }
536
637
  var ENGINE_TIMEOUT_MS = 1e4;
537
638
  var CALLER_VISIBLE_CEILING_MS = 5e4;
@@ -582,6 +683,7 @@ function isReplayUnsafeMethod(method) {
582
683
  return Object.prototype.hasOwnProperty.call(REPLAY_UNSAFE_REASONS, method);
583
684
  }
584
685
  var REPLAY_UNSAFE_METHOD_NAMES = Object.keys(REPLAY_UNSAFE_REASONS);
686
+ var STATE_CHANGING_ON_RETRY_METHOD_NAMES = ["snapshot"];
585
687
  function engineDeadlineMs(method, params) {
586
688
  if (method === "restore") {
587
689
  const steps = params?.steps;
@@ -638,6 +740,10 @@ var NARROWING_AUTHORITY = {
638
740
  mcp: "the gp_* tool that took this call publishes its own parameters, and their floors, in the schema shown beside that tool \u2014 read it there, not here",
639
741
  http: "this gateway publishes no parameter listing of its own, so the floors a body was accepted against are in the request contract this call was copied from"
640
742
  };
743
+ function clientClosingRemedy(surface) {
744
+ const readRoute = surface === "mcp" ? "gp_recent_reports lists the operations this session recorded and gp_last_evidence {operationId} returns one in full" : "this gateway keeps no operation trail of its own, so the stderr line this reply is written to is the record";
745
+ return `this shell's own engine client is ending, which measures nothing about the daemon: nothing here authorises touching the daemon's lifecycle, and no restart is authorised by this answer. What is unknown stays unknown: whether the daemon finished this request cannot be told from a client that stopped asking. The reply may still land, under the bound this shell already states \u2014 ${lateReplyRoute(surface)} \u2014 and this shutdown is one of the endings that bound names: the server log line written at this moment lists the requests this client stopped tracking. Read what happened rather than performing it a second time: ${readRoute}. Ask through a server process that still has a live engine client.`;
746
+ }
641
747
  function reIssueGuidance(surface) {
642
748
  return `wait for the outstanding reply rather than sending this request again: the daemon answers one request at a time, so a second copy of it now only queues behind the one still running and answers nothing sooner, and if the first did take effect the copy performs it a second time. What is worth narrowing here, and whether it can still be narrowed at the values this request already sent, is a fact about a schema, and this layer holds no schema for any request: ${NARROWING_AUTHORITY[surface]}. Re-send only after reading that, and only if the request is one whose repetition changes nothing`;
643
749
  }
@@ -793,8 +899,8 @@ var EngineJsonRpcClient = class {
793
899
  this.state.closed = true;
794
900
  this.teardown(new EngineCallError(
795
901
  GP_E_ENGINE_UNREACHABLE,
796
- "engine client closed",
797
- daemonUnreachableRemedy()
902
+ "engine client closed: this shell's own client is ending, so whether the daemon finished this request is not known here and this client can no longer attribute the reply that may still land",
903
+ clientClosingRemedy(this.surface)
798
904
  ));
799
905
  this.io.close();
800
906
  }
@@ -843,7 +949,11 @@ var EngineJsonRpcClient = class {
843
949
  if (typeof id === "number") {
844
950
  const entry = this.pending.get(id);
845
951
  if (!entry) {
846
- this.report(`engine sent a frame for request id ${id}, which this client never issued (or already gave up on): ${truncate(line)}`);
952
+ const tracked = [...this.pending.values()];
953
+ const held = tracked.length === 0 ? "nothing is tracked now" : `this client still tracks ${tracked.length} request(s): ` + tracked.map((kept) => "'" + kept.method + "' id " + kept.id + (kept.settled ? " (caller already answered)" : "")).join(", ");
954
+ this.report(
955
+ `engine sent a frame for request id ${id}, which this client never issued (or already gave up on); ` + held + ": " + truncate(line)
956
+ );
847
957
  return;
848
958
  }
849
959
  this.pending.delete(id);
@@ -858,8 +968,9 @@ var EngineJsonRpcClient = class {
858
968
  const target = this.oldestTracked();
859
969
  if (target) {
860
970
  if (target.settled) {
861
- this.pending.delete(target.id);
862
971
  this.reportLateReply(target, frame, true);
972
+ target.arrivalOrderConsumed = true;
973
+ this.retainForLateReply();
863
974
  return;
864
975
  }
865
976
  if (frame.error) {
@@ -920,9 +1031,20 @@ var EngineJsonRpcClient = class {
920
1031
  * Oldest request this client still tracks, settled ones included: that is the
921
1032
  * request the daemon's arrival-ordered answer belongs to. `Map` iteration is
922
1033
  * insertion order, which is the order the requests went out.
1034
+ *
1035
+ * An entry whose id-less answer has already been taken (`arrivalOrderConsumed`)
1036
+ * is passed over for *this* purpose while staying in the map for the echoed-id
1037
+ * lookup: the guess has been spent once and may not be spent again on the next
1038
+ * id-less frame, and the request it was spent on is still the request whose real
1039
+ * reply can arrive with its id (finding 中-4).
923
1040
  */
924
1041
  oldestTracked() {
925
- return this.pending.values().next().value;
1042
+ for (const entry of this.pending.values()) {
1043
+ if (!entry.arrivalOrderConsumed) {
1044
+ return entry;
1045
+ }
1046
+ }
1047
+ return void 0;
926
1048
  }
927
1049
  /**
928
1050
  * Route the reply that lands after its caller was already answered. `inferred`
@@ -1612,9 +1734,9 @@ function exponentialFromDigits(digits, power) {
1612
1734
  }
1613
1735
 
1614
1736
  // src/project-registry.ts
1615
- import fs2 from "node:fs";
1616
- import os2 from "node:os";
1617
- import path2 from "node:path";
1737
+ import fs3 from "node:fs";
1738
+ import os3 from "node:os";
1739
+ import path3 from "node:path";
1618
1740
  import { randomBytes } from "node:crypto";
1619
1741
  import { z as z4 } from "zod";
1620
1742
  var CROCKFORD_BODY_RE = "[0-9A-HJKMNP-TV-Z]{26}";
@@ -1653,7 +1775,7 @@ var PROTECTED_SUBTREES = [
1653
1775
  ];
1654
1776
  function systemHome() {
1655
1777
  try {
1656
- return os2.userInfo().homedir;
1778
+ return os3.userInfo().homedir;
1657
1779
  } catch (error) {
1658
1780
  throw new ProjectRegistryError(
1659
1781
  // Not GP_E_INTERNAL: `tools.ts` maps that code to "the projects file is
@@ -1665,7 +1787,7 @@ function systemHome() {
1665
1787
  }
1666
1788
  }
1667
1789
  function daemonProjectsPath() {
1668
- return path2.join(systemHome(), ".glasspane", "projects.json");
1790
+ return path3.join(systemHome(), ".glasspane", "projects.json");
1669
1791
  }
1670
1792
  var FIRMLINK_PREFIX = "/System/Volumes/Data";
1671
1793
  var FORCE_OVERWRITE_ENV = "GLASSPANE_PROJECTS_FORCE_OVERWRITE";
@@ -1696,7 +1818,7 @@ function projectsPath() {
1696
1818
  function loadRegistry(filePath) {
1697
1819
  let raw;
1698
1820
  try {
1699
- raw = fs2.readFileSync(filePath, "utf8");
1821
+ raw = fs3.readFileSync(filePath, "utf8");
1700
1822
  } catch (error) {
1701
1823
  if (nodeErrorCode(error) === "ENOENT") {
1702
1824
  return { projects: [], failure: null };
@@ -1765,7 +1887,7 @@ function requireUsableRegistry(filePath, snapshot) {
1765
1887
  }
1766
1888
  const discarded = `${filePath}.unreadable-${uniqueToken()}`;
1767
1889
  try {
1768
- fs2.renameSync(filePath, discarded);
1890
+ fs3.renameSync(filePath, discarded);
1769
1891
  } catch (error) {
1770
1892
  throw new ProjectRegistryError(
1771
1893
  GP_E_INTERNAL,
@@ -1784,7 +1906,7 @@ var STATE_DIR_MODE = 448;
1784
1906
  var STATE_FILE_MODE = 384;
1785
1907
  function tightenMode(target, want, kind) {
1786
1908
  try {
1787
- fs2.chmodSync(target, want);
1909
+ fs3.chmodSync(target, want);
1788
1910
  } catch (error) {
1789
1911
  throw new ProjectRegistryError(
1790
1912
  GP_E_INTERNAL,
@@ -1793,7 +1915,7 @@ function tightenMode(target, want, kind) {
1793
1915
  }
1794
1916
  let mode;
1795
1917
  try {
1796
- mode = fs2.statSync(target).mode & 511;
1918
+ mode = fs3.statSync(target).mode & 511;
1797
1919
  } catch (error) {
1798
1920
  throw new ProjectRegistryError(
1799
1921
  GP_E_INTERNAL,
@@ -1808,18 +1930,18 @@ function tightenMode(target, want, kind) {
1808
1930
  }
1809
1931
  }
1810
1932
  function saveProjects(filePath, entries) {
1811
- const dir = path2.dirname(filePath);
1933
+ const dir = path3.dirname(filePath);
1812
1934
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
1813
1935
  try {
1814
- fs2.mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
1936
+ fs3.mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
1815
1937
  tightenMode(dir, STATE_DIR_MODE, "directory");
1816
- fs2.writeFileSync(tmpPath, JSON.stringify(entries, null, 2), "utf8");
1938
+ fs3.writeFileSync(tmpPath, JSON.stringify(entries, null, 2), "utf8");
1817
1939
  tightenMode(tmpPath, STATE_FILE_MODE, "file");
1818
- fs2.renameSync(tmpPath, filePath);
1940
+ fs3.renameSync(tmpPath, filePath);
1819
1941
  tightenMode(filePath, STATE_FILE_MODE, "file");
1820
1942
  } catch (error) {
1821
1943
  try {
1822
- fs2.unlinkSync(tmpPath);
1944
+ fs3.unlinkSync(tmpPath);
1823
1945
  } catch {
1824
1946
  }
1825
1947
  if (error instanceof ProjectRegistryError) {
@@ -1927,7 +2049,7 @@ function assertSingleIdentity(entry) {
1927
2049
  }
1928
2050
  function daemonRestartNotice(filePath) {
1929
2051
  const daemonPath = daemonProjectsPath();
1930
- if (path2.resolve(filePath) !== path2.resolve(daemonPath)) {
2052
+ if (path3.resolve(filePath) !== path3.resolve(daemonPath)) {
1931
2053
  return `Saved in ${filePath}. The background service does not read this path because of anything in its own configuration: its default root is the folder under this user's home record, where it loads ${daemonPath} once when it starts, and ${PROJECTS_FILE_ENV} \u2014 the variable that pointed this write here \u2014 is honoured by this MCP shell only, so as far as the service's own configuration goes it never reads that file, and no restart of the service will make it load this one. ${rootDiscriminator()} To land the entry where that default root looks: unset ${PROJECTS_FILE_ENV} for this shell (or point it at ${daemonPath}) and repeat gp_project_set, then restart the background service so it reloads \u2014 ${restartCommand()}`;
1932
2054
  }
1933
2055
  return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
@@ -1959,7 +2081,7 @@ function checkAgentPath(field, value) {
1959
2081
  throw badPath(field, value, `must not contain "." or ".." components (got "${value}")`);
1960
2082
  }
1961
2083
  }
1962
- const logical = path2.posix.normalize(value);
2084
+ const logical = path3.posix.normalize(value);
1963
2085
  const given = stripFirmlink(logical);
1964
2086
  const resolved = stripFirmlink(resolveExistingAncestor(logical));
1965
2087
  const refusal = strongestRefusal(given === resolved ? [resolved] : [resolved, given], resolved);
@@ -2008,7 +2130,7 @@ function refuseHomeDotDirectory(target, home) {
2008
2130
  return named(`a dot-directory of the current user's home directory (~/${first}), which is where that account's credentials and client configuration live (\`~/.ssh\`, \`~/.aws\`, \`~/.gnupg\`, \`~/.config\`) or the daemon's own private state (\`~/.glasspane\`); the daemon chmods a registered storage root to 0700 and writes evidence packs into it, and prunes expired ones out of it, so this is not a place a project archive may be pointed at \u2014 name a directory inside the project instead, e.g. "the project tree/${first}"`);
2009
2131
  }
2010
2132
  function refuseCandidate(target) {
2011
- const home = stripFirmlink(path2.posix.normalize(systemHome()));
2133
+ const home = stripFirmlink(path3.posix.normalize(systemHome()));
2012
2134
  if (target === home) return named("the current user's home directory");
2013
2135
  if (isInside(`${home}/Library`, target)) return named("inside the current user's Library folder");
2014
2136
  const homeDot = refuseHomeDotDirectory(target, home);
@@ -2037,7 +2159,7 @@ function refuseUnownedTree(target) {
2037
2159
  }
2038
2160
  let stat;
2039
2161
  try {
2040
- stat = fs2.statSync(ancestor);
2162
+ stat = fs3.statSync(ancestor);
2041
2163
  } catch (error) {
2042
2164
  return `under a nearest existing directory ${ancestor} that cannot be inspected (${describeError(error)})`;
2043
2165
  }
@@ -2052,10 +2174,10 @@ function refuseUnownedTree(target) {
2052
2174
  function nearestExistingAncestor(target) {
2053
2175
  let cursor = target;
2054
2176
  for (; ; ) {
2055
- if (fs2.existsSync(cursor)) {
2177
+ if (fs3.existsSync(cursor)) {
2056
2178
  return cursor;
2057
2179
  }
2058
- const parent = path2.posix.dirname(cursor);
2180
+ const parent = path3.posix.dirname(cursor);
2059
2181
  if (parent === cursor) return parent;
2060
2182
  cursor = parent;
2061
2183
  }
@@ -2075,12 +2197,12 @@ function resolveExistingAncestor(target) {
2075
2197
  const missingTail = [];
2076
2198
  for (; ; ) {
2077
2199
  try {
2078
- const real = fs2.realpathSync(cursor);
2079
- return missingTail.length === 0 ? real : path2.posix.join(real, ...missingTail);
2200
+ const real = fs3.realpathSync(cursor);
2201
+ return missingTail.length === 0 ? real : path3.posix.join(real, ...missingTail);
2080
2202
  } catch {
2081
- const parent = path2.posix.dirname(cursor);
2203
+ const parent = path3.posix.dirname(cursor);
2082
2204
  if (parent === cursor) return target;
2083
- missingTail.unshift(path2.posix.basename(cursor));
2205
+ missingTail.unshift(path3.posix.basename(cursor));
2084
2206
  cursor = parent;
2085
2207
  }
2086
2208
  }
@@ -2126,14 +2248,9 @@ var OPERATION_ID_PATTERN2 = /^op_[0-9A-HJKMNP-TV-Z]{26}$/;
2126
2248
  var OptionalOperationIdSchema = z5.string().regex(OPERATION_ID_PATTERN2).optional();
2127
2249
  var OptionalProjectIdSchema = z5.string().regex(/^prj_[0-9A-HJKMNP-TV-Z]{26}$/).optional();
2128
2250
  var ExpectSchema = z5.union([z5.string().max(512), z5.boolean()]);
2129
- var ShellSelectorSchema = z5.strictObject({
2130
- role: z5.string().min(1).max(SELECTOR_MAX_LENGTH),
2131
- title: z5.string().min(1).max(SELECTOR_MAX_LENGTH).optional(),
2132
- identifier: z5.string().max(SELECTOR_MAX_LENGTH).optional()
2133
- });
2134
2251
  var SnapshotIdSchema = z5.string().regex(/^snap_[0-9A-HJKMNP-TV-Z]{26}$/);
2135
2252
  var ActStepSchema = z5.strictObject({
2136
- selector: ShellSelectorSchema,
2253
+ selector: SelectorSchema,
2137
2254
  action: ActionSchema
2138
2255
  });
2139
2256
  var RestoreStepsSchema = z5.array(ActStepSchema).min(1).max(64).optional();
@@ -2173,12 +2290,12 @@ var AuditUiArgs = z5.strictObject({
2173
2290
  minHitTargetPt: OptionalHitTargetSchema
2174
2291
  });
2175
2292
  var ActArgs = z5.strictObject({
2176
- selector: ShellSelectorSchema,
2293
+ selector: SelectorSchema,
2177
2294
  action: ActionSchema,
2178
2295
  degrade: z5.boolean().optional()
2179
2296
  });
2180
2297
  var AssertElementArgs = z5.strictObject({
2181
- selector: ShellSelectorSchema,
2298
+ selector: SelectorSchema,
2182
2299
  property: AssertionPropertySchema,
2183
2300
  expected: ExpectSchema
2184
2301
  });
@@ -2222,9 +2339,11 @@ var selectorJsonSchema = {
2222
2339
  type: "object",
2223
2340
  additionalProperties: false,
2224
2341
  properties: {
2225
- // `minLength` here is the advertised half of {@link ShellSelectorSchema};
2342
+ // `minLength` here is the advertised half of the kernel's `SelectorSchema`;
2226
2343
  // `test/tools.test.mjs` compares the two against each other and against the
2227
- // daemon's own rule, so neither side can be tightened or loosened alone.
2344
+ // daemon's own rule, and `test/consumer-consistency.test.mjs` compares both
2345
+ // against the published `kernel/schemas/evidence-pack.schema.json`, so no
2346
+ // side can be tightened or loosened alone.
2228
2347
  role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
2229
2348
  title: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH },
2230
2349
  identifier: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
@@ -2538,6 +2657,25 @@ function attachIdentityOf(result) {
2538
2657
  const bundleId = typeof record.bundleId === "string" ? record.bundleId : "<no-bundle-id>";
2539
2658
  return `${pid}|${bundleId}|${appName}`;
2540
2659
  }
2660
+ var TARGET_SELECTING_KNOBS = {
2661
+ gp_act: {
2662
+ param: "selector",
2663
+ picks: "which control the action is performed on",
2664
+ substitute: "a different control takes the press, and the user's screen changes in a way no one asked for"
2665
+ },
2666
+ gp_assert_element: {
2667
+ param: "selector",
2668
+ picks: "which element the claim is about",
2669
+ substitute: "the answer then describes a different element, while this session's trail still files it as the verdict on the claim you first made"
2670
+ }
2671
+ };
2672
+ function targetSelectingKnob(specName, paramName) {
2673
+ if (!Object.prototype.hasOwnProperty.call(TARGET_SELECTING_KNOBS, specName)) {
2674
+ return null;
2675
+ }
2676
+ const entry = TARGET_SELECTING_KNOBS[specName];
2677
+ return entry !== void 0 && entry.param === paramName ? { picks: entry.picks, substitute: entry.substitute } : null;
2678
+ }
2541
2679
  function narrowingKnobsFor(spec, params) {
2542
2680
  const properties = spec.inputSchema.properties ?? {};
2543
2681
  const knobs = [];
@@ -2552,11 +2690,21 @@ function narrowingKnobsFor(spec, params) {
2552
2690
  } else if (name === "limit") {
2553
2691
  knobs.push({ name, kind: "limit", minimum: schema.minimum, maximum: schema.maximum, sent });
2554
2692
  } else if (name === "selector" || name === "role" || name === "title") {
2555
- knobs.push({ name, kind: "selector", sent: void 0 });
2693
+ const target = targetSelectingKnob(spec.name, name);
2694
+ knobs.push(target === null ? { name, kind: "selector", sent: void 0 } : { name, kind: "target", sent: void 0, picks: target.picks, substitute: target.substitute });
2556
2695
  }
2557
2696
  }
2558
2697
  return knobs;
2559
2698
  }
2699
+ function replyShrinkingKnobs(knobs) {
2700
+ return knobs.filter((knob) => knob.kind !== "target");
2701
+ }
2702
+ function targetKnobs(knobs) {
2703
+ return knobs.filter((knob) => knob.kind === "target");
2704
+ }
2705
+ function describeTargetKnob(knob) {
2706
+ return `'${knob.name}' names ${knob.picks ?? "which element this operation is about"}, so changing it is not a smaller or cheaper request \u2014 it is a different operation, and ${knob.substitute ?? "the answer then describes something else"}`;
2707
+ }
2560
2708
  function atAdvertisedFloor(knob) {
2561
2709
  return knob.minimum !== void 0 && knob.sent !== void 0 && knob.sent <= knob.minimum;
2562
2710
  }
@@ -2570,6 +2718,8 @@ function knobSubject(kind) {
2570
2718
  return "the image";
2571
2719
  case "selector":
2572
2720
  return "the reply";
2721
+ case "target":
2722
+ return "the element the operation is about";
2573
2723
  }
2574
2724
  }
2575
2725
  function describeKnob(knob) {
@@ -2586,16 +2736,20 @@ function describeKnob(knob) {
2586
2736
  return knob.name === "selector" ? "a more specific selector (aim at one element)" : `a tighter ${knob.name} filter`;
2587
2737
  case "scale":
2588
2738
  return `a smaller scale${bounds}${knob.sent !== void 0 ? `, currently ${knob.sent}` : ""}`;
2739
+ case "target":
2740
+ return describeTargetKnob(knob);
2589
2741
  }
2590
2742
  }
2591
2743
  function oversizedReplyAdvice(spec, params) {
2592
2744
  const knobs = narrowingKnobsFor(spec, params);
2593
2745
  const atFloor = knobs.filter(atAdvertisedFloor);
2594
- const shrinkable = knobs.filter((knob) => !atAdvertisedFloor(knob));
2746
+ const target = targetKnobs(knobs);
2747
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
2595
2748
  const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
2596
- const floorNote = atFloor.length === 0 ? "" : `${atFloor.map(describeKnob).join("; ")}; `;
2749
+ const refused = [...atFloor.map(describeKnob), ...target.map(describeTargetKnob)];
2750
+ const floorNote = refused.length === 0 ? "" : `${refused.join("; ")}; `;
2597
2751
  if (shrinkable.length === 0) {
2598
- return knobs.length > 0 ? head + `${atFloor.map(describeKnob).join("; ")} \u2014 nothing left to narrow on this request: ask a smaller question (one element, one region) rather than retrying it unchanged; the connection is intact and the daemon keeps answering other requests` : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2752
+ return knobs.length > 0 ? head + floorNote + "nothing left to narrow on this request: " + (target.length > 0 ? "this request already carries one element and one region, so read the tree with gp_observe and, if the element you meant is another one, make that its own claim rather than treating this answer as a size problem" : "ask a smaller question (one element, one region) rather than retrying it unchanged") + "; the connection is intact and the daemon keeps answering other requests" : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
2599
2753
  }
2600
2754
  return head + floorNote + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
2601
2755
  }
@@ -2605,23 +2759,33 @@ var REPLAY_UNSAFE_PAYLOAD_HARMS = {
2605
2759
  attach: "it re-points the daemon at another app, and a changed attach discards the evidence history a request that has not answered yet is being recorded in"
2606
2760
  };
2607
2761
  function replayUnsafePayloadAdvice(spec, method, params) {
2608
- const shrinkable = narrowingKnobsFor(spec, params).filter((knob) => !atAdvertisedFloor(knob));
2762
+ const knobs = narrowingKnobsFor(spec, params);
2763
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
2764
+ const target = targetKnobs(knobs);
2609
2765
  const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
2610
2766
  const harm = REPLAY_UNSAFE_PAYLOAD_HARMS[method] ?? "it changes daemon state";
2611
- const narrower = shrinkable.length > 0 ? `with ${shrinkable.map(describeKnob).join(" and ")}` : "only after narrowing it (this tool advertises no parameter that shrinks a reply, so ask a smaller question: one element, one region)";
2767
+ const narrower = shrinkable.length > 0 ? `with ${shrinkable.map(describeKnob).join(" and ")}` : target.length > 0 ? `exactly as it stands, with the same ${target.map((knob) => knob.name).join(" and ")} (${target.map(describeTargetKnob).join("; ")})` : "only after narrowing it (this tool advertises no parameter that shrinks a reply, so ask a smaller question: one element, one region)";
2612
2768
  return head + `do not re-issue this request. '${spec.name}' already reached the daemon and was answered \u2014 only the answer was dropped for exceeding one frame \u2014 and ${harm}, so sending it again performs the change a second time rather than recovering the first. Read what happened instead: gp_recent_reports lists the operations this session recorded and gp_last_evidence {operationId} returns one of them in full, and gp_observe / gp_assert_element read the app the daemon is attached to now without performing anything (gp_probe_status answers on the same connection and names the apps with a live probe with their pids). The connection is intact and the daemon keeps answering other requests; if that reading shows this request never took effect, it can be sent again ${narrower}, and a retried identical request will be dropped identically.`;
2613
2769
  }
2614
2770
  function slowReplyAdvice(spec, params) {
2615
2771
  const knobs = narrowingKnobsFor(spec, params);
2616
2772
  const atFloor = knobs.filter(atAdvertisedFloor);
2617
- const shrinkable = knobs.filter((knob) => !atAdvertisedFloor(knob));
2773
+ const target = targetKnobs(knobs);
2774
+ const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
2618
2775
  const floorNote = atFloor.length === 0 ? "" : ` ${atFloor.map(describeKnob).join("; ")}.`;
2619
- const narrowed = shrinkable.length === 0 ? `there is nothing left to narrow on this request:${floorNote} ask a smaller question (one element, one region) rather than re-sending the same one and waiting out the same deadline on it` : `once it is answered, re-send '${spec.name}' with ${shrinkable.map(describeKnob).join(" and ")} \u2014 these are the parameters this tool itself advertises as deciding how much work one answer carries${floorNote}`;
2776
+ const narrowed = shrinkable.length > 0 ? `once it is answered, re-send '${spec.name}' with ${shrinkable.map(describeKnob).join(" and ")} \u2014 these are the parameters this tool itself advertises as deciding how much work one answer carries${floorNote}` : target.length > 0 ? `there is nothing left to narrow on this request, and no parameter of '${spec.name}' can stand in for one: ${target.map(describeTargetKnob).join("; ")}. Wait for the outstanding reply \u2014 it is attributed and recorded in this session's trail when it lands \u2014 and read the app rather than re-aiming this claim: gp_observe lists what is on screen, and gp_probe_status names the apps with a live probe` : `there is nothing left to narrow on this request:${floorNote} ask a smaller question (one element, one region) rather than re-sending the same one and waiting out the same deadline on it`;
2620
2777
  const probeGuide = LIVENESS_PROBE_OUTCOMES.map((outcome) => `${outcome} \u2014 ${livenessProbeDecision(outcome)}`).join("; ");
2621
2778
  return `the daemon serves one request at a time and is still working on this one, so this answer describes a busy daemon, not a dead one: wait for it, and do not put a second copy of the same request on the wire while the first is outstanding \u2014 it queues behind the work that is already late. ${narrowed}. If your client can send a second request while this one is still outstanding, confirm the daemon is alive with gp_probe_status (this shell does not queue one MCP request behind another) and read its answer as: ${probeGuide}. A restart is authorised only by ${livenessProbeDecision("unreachable")}. Nothing is discarded by this shell having answered early: ${lateReplyRoute("mcp")}`;
2622
2779
  }
2780
+ var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
2781
+ ...REPLAY_UNSAFE_METHOD_NAMES,
2782
+ ...STATE_CHANGING_ON_RETRY_METHOD_NAMES
2783
+ ]);
2784
+ function isNarrowableByRetryMethod(method) {
2785
+ return !NOT_NARROWABLE_BY_RETRY.has(method);
2786
+ }
2623
2787
  function toolAuthorsTimeoutAdvice(spec, method, params) {
2624
- return !isReplayUnsafeMethod(method) && narrowingKnobsFor(spec, params).length > 0;
2788
+ return isNarrowableByRetryMethod(method) && narrowingKnobsFor(spec, params).length > 0;
2625
2789
  }
2626
2790
  async function readTrail(session, turn, limit) {
2627
2791
  await turn.acquire();
@@ -2672,8 +2836,12 @@ async function runValidatedTool(spec, value, engine, session, turn) {
2672
2836
  } finally {
2673
2837
  turn.release();
2674
2838
  }
2839
+ const content = [{ type: "text", text: canonicalJson(raw) }];
2840
+ if (spec.name === "gp_diagnose") {
2841
+ content.push({ type: "text", text: readUpdateState().summary });
2842
+ }
2675
2843
  return {
2676
- content: [{ type: "text", text: canonicalJson(raw) }],
2844
+ content,
2677
2845
  isError: false
2678
2846
  };
2679
2847
  } catch (error) {
@@ -3157,7 +3325,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3157
3325
  "2025-03-26",
3158
3326
  MCP_PROTOCOL_VERSION
3159
3327
  ];
3160
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.3.1" };
3328
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.4.0" };
3161
3329
  var PARSE_ERROR = -32700;
3162
3330
  var INVALID_REQUEST = -32600;
3163
3331
  var METHOD_NOT_FOUND = -32601;
package/dist/tools.d.ts CHANGED
@@ -357,6 +357,8 @@ export declare function replayUnsafePayloadAdvice(spec: ToolSpec, method: string
357
357
  * exact harm R8-高1 removed.
358
358
  */
359
359
  export declare function slowReplyAdvice(spec: ToolSpec, params: Record<string, unknown>): string;
360
+ /** May the tool layer answer a timeout on `method` by telling the caller to re-send narrower? */
361
+ export declare function isNarrowableByRetryMethod(method: string): boolean;
360
362
  /**
361
363
  * Does the tool layer have more to say about a timeout on `method` than the
362
364
  * transport does?
@@ -366,8 +368,9 @@ export declare function slowReplyAdvice(spec: ToolSpec, params: Record<string, u
366
368
  * see the tool's schema. Everywhere else the transport's own sentence stands —
367
369
  * including `gp_probe_status`, whose remedy the transport writes specially because
368
370
  * a probe that went unanswered must not be told to send a probe (and must not
369
- * name a restart at all). Replay-unsafe methods are the transport's too: its
370
- * remedy already forbids the re-issue, and this layer adds no reason to re-issue.
371
+ * name a restart at all). And a method whose re-issue changes daemon state is not
372
+ * narrowed by re-sending it at all ({@link NOT_NARROWABLE_BY_RETRY}): its answer
373
+ * is the transport's, which orders no re-send.
371
374
  */
372
375
  export declare function toolAuthorsTimeoutAdvice(spec: ToolSpec, method: string, params: Record<string, unknown>): boolean;
373
376
  /**
@@ -0,0 +1,25 @@
1
+ /** Env the daemon's own `--state-dir` maps to for this process (X-22 family). */
2
+ export declare const STATE_DIR_ENV = "GLASSPANE_STATE_DIR";
3
+ /**
4
+ * The home the *other* processes use. `os.homedir()` reads `$HOME`, which a caller
5
+ * controls; `os.userInfo().homedir` reads the password record, which is what
6
+ * `NSHomeDirectory()` in the panel and `StateRoot.homeDefault()` in the daemon both
7
+ * return. Refuse rather than fall back — the same rule `project-registry.ts` states
8
+ * at length, and for the same reason: a redirected `$HOME` here means reading a
9
+ * state file that describes nobody's machine.
10
+ */
11
+ export declare function recordHome(): string | null;
12
+ /** Where `update-state.json` lives for this process, or null when it cannot be named. */
13
+ export declare function updateStateFile(env?: NodeJS.ProcessEnv, stateRootOverride?: string): string | null;
14
+ export interface UpdateStateReading {
15
+ kind: "read" | "absent" | "unreadable" | "unresolvable";
16
+ stateFile: string | null;
17
+ summary: string;
18
+ }
19
+ /**
20
+ * Read and render. Never throws: `gp_diagnose` answers about an operation, and a
21
+ * missing or broken update file is a fact about the machine, not a reason to fail
22
+ * the diagnosis.
23
+ */
24
+ export declare function readUpdateState(env?: NodeJS.ProcessEnv, stateRootOverride?: string): UpdateStateReading;
25
+ //# sourceMappingURL=update-state.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",
@@ -196,10 +196,12 @@
196
196
  "properties": {
197
197
  "role": {
198
198
  "type": "string",
199
+ "minLength": 1,
199
200
  "maxLength": 512
200
201
  },
201
202
  "title": {
202
203
  "type": "string",
204
+ "minLength": 1,
203
205
  "maxLength": 512
204
206
  },
205
207
  "identifier": {