glasspane-mcp 1.1.1 → 1.1.2

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.1.1";
29
+ readonly version: "1.1.2";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -12,10 +12,36 @@ export declare function daemonUnreachableRemedy(): string;
12
12
  /** Env override for the daemon socket path (default below). */
13
13
  export declare const ENGINE_SOCKET_ENV = "GLASSPANE_ENGINE_SOCK";
14
14
  /**
15
- * The daemon listens here by default (spec §3.1). Single source: the CLI
16
- * argument parser in index.ts and the reconnecting transport both resolve the
17
- * path through this function, so an env override cannot be honoured on one side
18
- * only.
15
+ * Where this shell connects when nothing was named, in order: `GLASSPANE_ENGINE_SOCK`
16
+ * (the env var {@link ENGINE_SOCKET_ENV} spells), then the explicit
17
+ * `--socket-path` this shell's own CLI accepts, then the fallback below.
18
+ *
19
+ * The fallback is a **guess made from `$HOME`**, and it is worth being exact
20
+ * about which guess: `os.homedir()` reads `$HOME`, while the daemon's own
21
+ * default is `StateRoot.homeDefault()` = `NSHomeDirectory() + "/.glasspane"`,
22
+ * which on macOS does **not** consult `$HOME` — the same non-inheritance that
23
+ * has already cost this project real data, and the reason a caller cannot
24
+ * isolate a run by exporting `HOME`. So in a process whose `$HOME` points into
25
+ * a sandbox the two sides resolve **different directories**: this shell goes
26
+ * looking for `$HOME/.glasspane/engine.sock` inside the sandbox while the daemon
27
+ * is still serving only the socket under the real home. Nor is there a third
28
+ * case to cover by guessing better — a daemon started with `--state-dir <root>`
29
+ * listens on `<root>/daemon.sock`, which no value of `$HOME` points at.
30
+ *
31
+ * The reliable route is therefore to stop deriving and to say which socket: set
32
+ * `GLASSPANE_ENGINE_SOCK`, or pass `--socket-path`. The installer's launchd job
33
+ * hands the daemon an explicit `--socket-path` already, which is what makes an
34
+ * installed pair agree — so read that value back rather than recomputing it;
35
+ * this shell's `--help` gives the command.
36
+ *
37
+ * Deliberately not "fixed" by asking the daemon: before a connection exists
38
+ * there is no daemon to ask, and `hello` is itself answered after connecting.
39
+ * Matching the daemon's home lookup from Node would take an FFI call to
40
+ * `NSHomeDirectory()`, which this shell does not have.
41
+ *
42
+ * Single source all the same: the CLI argument parser in index.ts and the
43
+ * reconnecting transport both resolve the path through this function, so an env
44
+ * override cannot be honoured on one side only.
19
45
  */
20
46
  export declare function defaultSocketPath(): string;
21
47
  /** Fallback deadline for a method missing from the table below. */
package/dist/index.js CHANGED
@@ -1075,9 +1075,11 @@ function evidenceSummaryLines(pack) {
1075
1075
  }
1076
1076
  const act = pack.signals.act;
1077
1077
  lines.push(`act ${act.action} ${selectorText(act.selector)}: ${act.actConfirmed ? "confirmed" : "rejected"}`);
1078
- if (pack.signals.axEvent !== void 0) {
1078
+ if (pack.signals.axEvent !== void 0 && pack.signals.axEvent !== null) {
1079
1079
  const ax = pack.signals.axEvent;
1080
1080
  lines.push(`axEvent: changed=${ax.axChanged} nodes=${ax.nodeCount} latencyMs=${doubleText(ax.latencyMs)}`);
1081
+ } else {
1082
+ lines.push(AX_EVENT_NOT_MEASURED);
1081
1083
  }
1082
1084
  lines.push(
1083
1085
  pack.signals.handlerProbe !== void 0 && pack.signals.handlerProbe !== null ? handlerProbeText(pack.signals.handlerProbe) : HANDLER_PROBE_NOT_MEASURED
@@ -1085,7 +1087,7 @@ function evidenceSummaryLines(pack) {
1085
1087
  lines.push(
1086
1088
  pack.signals.stateDiff !== void 0 && pack.signals.stateDiff !== null ? stateDiffText(pack.signals.stateDiff) : STATE_DIFF_NOT_MEASURED
1087
1089
  );
1088
- if (pack.signals.pixelDiff !== void 0) {
1090
+ if (pack.signals.pixelDiff !== void 0 && pack.signals.pixelDiff !== null) {
1089
1091
  const px = pack.signals.pixelDiff;
1090
1092
  let line = `pixelDiff: changedRatio=${doubleText(px.changedPixelRatio)} windowId=${px.windowId}`;
1091
1093
  if (px.bounds !== null) {
@@ -1093,14 +1095,20 @@ function evidenceSummaryLines(pack) {
1093
1095
  line += ` bounds=${doubleText(b.x)},${doubleText(b.y)},${doubleText(b.width)},${doubleText(b.height)}`;
1094
1096
  }
1095
1097
  lines.push(line);
1098
+ } else {
1099
+ lines.push(PIXEL_DIFF_NOT_MEASURED);
1096
1100
  }
1097
- if (pack.signals.responsiveness !== void 0) {
1101
+ if (pack.signals.responsiveness !== void 0 && pack.signals.responsiveness !== null) {
1098
1102
  const r = pack.signals.responsiveness;
1099
1103
  lines.push(`responsiveness: responsive=${r.responsive} pingMs=${doubleText(r.pingMs)}`);
1104
+ } else {
1105
+ lines.push(RESPONSIVENESS_NOT_MEASURED);
1100
1106
  }
1101
- if (pack.signals.crash !== void 0) {
1107
+ if (pack.signals.crash !== void 0 && pack.signals.crash !== null) {
1102
1108
  const c = pack.signals.crash;
1103
1109
  lines.push(`crash: aliveBefore=${c.processAliveBefore} aliveAfter=${c.processAliveAfter}`);
1110
+ } else {
1111
+ lines.push(CRASH_NOT_MEASURED);
1104
1112
  }
1105
1113
  return lines;
1106
1114
  }
@@ -1154,6 +1162,10 @@ function escapeHTML(value) {
1154
1162
  var NO_DIAGNOSIS_TEXT = "\u2014 (no diagnosis recorded: this pack carries none; run gp_diagnose for this operationId, then gp_export_evidence again)";
1155
1163
  var HANDLER_PROBE_NOT_MEASURED = "handlerProbe: not measured (no probe connection served this act window)";
1156
1164
  var STATE_DIFF_NOT_MEASURED = "stateDiff: not measured (the probe reported no state channel)";
1165
+ var AX_EVENT_NOT_MEASURED = "axEvent: not measured (this pack carries no AX tree digest)";
1166
+ var PIXEL_DIFF_NOT_MEASURED = "pixelDiff: not measured (this pack carries no pixel comparison)";
1167
+ var RESPONSIVENESS_NOT_MEASURED = "responsiveness: not measured (this pack carries no responsiveness round trip)";
1168
+ var CRASH_NOT_MEASURED = "crash: not measured (this pack carries no process liveness sample)";
1157
1169
  var MAX_HANDLERS_SHOWN = 8;
1158
1170
  var MAX_ENTRIES_SHOWN = 8;
1159
1171
  function sections(pack, diagnostics) {
@@ -1736,6 +1748,15 @@ var ObserveArgs = z5.strictObject({
1736
1748
  maxDepth: OptionalDepthSchema,
1737
1749
  role: OptionalRoleSchema
1738
1750
  });
1751
+ var OptionalHitTargetSchema = z5.number().min(1).max(400).optional();
1752
+ var OptionalScaleSchema = z5.number().min(0.1).max(1).optional();
1753
+ var CaptureViewArgs = z5.strictObject({
1754
+ scale: OptionalScaleSchema
1755
+ });
1756
+ var AuditUiArgs = z5.strictObject({
1757
+ maxDepth: OptionalDepthSchema,
1758
+ minHitTargetPt: OptionalHitTargetSchema
1759
+ });
1739
1760
  var ActArgs = z5.strictObject({
1740
1761
  selector: SelectorSchema,
1741
1762
  action: ActionSchema,
@@ -1828,7 +1849,7 @@ var TOOL_SPECS = [
1828
1849
  },
1829
1850
  {
1830
1851
  name: "gp_act",
1831
- description: "Perform a UI action on the attached app and confirm it. If the daemon reports GP_E_BUSY_INPUT (real user input is contaminating the window), retry later, or set degrade: true to proceed immediately with the contamination recorded in evidence (attribution becomes weak + contaminated=true) \u2014 never silently clean.",
1852
+ description: "Perform a UI action on the attached app and confirm it. If the daemon reports GP_E_BUSY_INPUT (real user input is contaminating the window), retry later, or set degrade: true to proceed immediately with the contamination recorded in evidence (attribution becomes weak + contaminated=true) \u2014 never silently clean. Prefer selector.identifier over selector.title to name the element: a title is not a stable handle (a measured case: every act against a title-keyed selector reported actConfirmed while the app's handler never ran, because the press resolved to something that was not the button). actConfirmed means the accessibility action was accepted \u2014 not that the intended element handled it; to prove the click itself, read the probe's handler hit or the state change, or assert_element against a value that the action is supposed to change.",
1832
1853
  engineMethod: "act",
1833
1854
  inputSchema: {
1834
1855
  type: "object",
@@ -1861,6 +1882,34 @@ var TOOL_SPECS = [
1861
1882
  },
1862
1883
  validate: zodBridge(AssertElementArgs)
1863
1884
  },
1885
+ {
1886
+ name: "gp_audit_ui",
1887
+ description: "Audit the attached app's layout for operability: element geometry plus deterministic rules report zero-sized or out-of-window controls (blocking), undersized hit targets, clipped elements and frame overlaps (advisory). The verdict distinguishes pass from insufficient: unmeasured geometry never counts as a clean result. Read-only \u2014 it records no operation.",
1888
+ engineMethod: "audit_ui",
1889
+ inputSchema: {
1890
+ type: "object",
1891
+ additionalProperties: false,
1892
+ properties: {
1893
+ maxDepth: { type: "integer", minimum: 1, maximum: 10 },
1894
+ minHitTargetPt: { type: "number", minimum: 1, maximum: 400 }
1895
+ }
1896
+ },
1897
+ validate: zodBridge(AuditUiArgs)
1898
+ },
1899
+ {
1900
+ name: "gp_capture_view",
1901
+ description: "Send the attached app's current window to your model once, as a PNG, for visual review. This is the only tool that moves image pixels, and it is a pass-through: the engine encodes the capture and writes nothing to disk (the result says persisted: false). Use it for what measurements cannot judge \u2014 visual taste, layout that reads as wrong, content with no accessibility tree. For anything assertable, prefer gp_audit_ui / gp_assert_element: a measurement repeats, a visual impression does not. `scale` is a pixel-density factor applied to the captured image (window size is reported separately in points as pointSize), and the summary states both the requested scale and the appliedScale actually present in the image. WARNING: the image enters this conversation, so anything your model provider logs will retain whatever was on screen. Needs the daemon's Screen Recording grant; oversized windows are refused with a suggested scale rather than silently downsampled.",
1902
+ engineMethod: "capture_view",
1903
+ inputSchema: {
1904
+ type: "object",
1905
+ additionalProperties: false,
1906
+ properties: {
1907
+ scale: { type: "number", minimum: 0.1, maximum: 1 }
1908
+ }
1909
+ },
1910
+ validate: zodBridge(CaptureViewArgs),
1911
+ execute: captureView
1912
+ },
1864
1913
  {
1865
1914
  name: "gp_diagnose",
1866
1915
  description: "Diagnose the most recent (or the given) operation.",
@@ -1934,7 +1983,7 @@ var TOOL_SPECS = [
1934
1983
  },
1935
1984
  {
1936
1985
  name: "gp_probe_status",
1937
- description: "List GlassPaneProbe connections and whether the attached app has a live probe \u2014 probe presence is what makes T4/T5/T7/T8 verdicts and tier-1 checkpoint restores decidable. `probes` holds **live** registrations only (every row `connected: true`), so a probe that dropped is absent from it rather than flagged inside it: read `recentDisconnections` for that history (`pid`, `disconnectReason`, `disconnectedAt`, per-pid `drops`), and `disconnections` is just the total count since start. Each live row can also carry the frame-accounting that makes a zero verdict readable: `stateFramesUnmappedSource` (daemon-side, per pid), and `droppedEvents` / `droppedWrites` / `rejectedKeys` as the probe itself reported them \u2014 those are **absent when unreported**, which is not the same as zero, and `unmapableStateFrames` is the daemon-side total that survives a disconnect.",
1986
+ description: "List GlassPaneProbe connections and whether the attached app has a live probe \u2014 probe presence is what makes T4/T5/T7/T8 verdicts and tier-1 checkpoint restores decidable. `attachedHasProbe` is that yes/no for the attached app; `probes` holds **live** registrations only (every row `connected: true`), so a probe that dropped is absent from it rather than flagged inside it: read `recentDisconnections` for that history (`pid`, `disconnectReason`, `disconnectedAt`, per-pid `drops`), and `disconnections` is just the total count since start. Each live row can also carry the frame-accounting that makes a zero verdict readable: `stateFramesUnmappedSource` (daemon-side, per pid), and `droppedEvents` / `droppedWrites` / `rejectedKeys` as the probe itself reported them \u2014 those are **absent when unreported**, which is not the same as zero, and `unmapableStateFrames` is the daemon-side total that survives a disconnect. The same reply is where the T9 degradation watch is readable without performing an action, under `degradation` (absent when this daemon runs without T9 wired): `tier` is `healthy` / `watch` / `degrading`, and `judged` says whether that tier is a measurement at all \u2014 `judged: false` with `tier: healthy` means the window was too thin or too short to read, **not** that the app is clean; `basis` spells out which of those it is with the numbers, alongside `samples`/`minimumSamples` and `spanSeconds`/`minimumSpanSeconds`. `longSession` marks the window spanning at least the long-session threshold; it is context for the trend, and it never changes `tier` by itself. `drivers` appears only when something is trending, and `slopes` carries only the channels that produced one (`pingMsPerSec`, `memoryBytesPerSec`, `handlesPerSec`) \u2014 a channel missing from `slopes` was never read, which is not a rate of zero.",
1938
1987
  engineMethod: "probe_status",
1939
1988
  inputSchema: {
1940
1989
  type: "object",
@@ -2161,6 +2210,69 @@ function packForReport(pack, measuredSchemaVersion) {
2161
2210
  }
2162
2211
  return { ...pack, schemaVersion: `${measuredSchemaVersion} (read as ${pack.schemaVersion})` };
2163
2212
  }
2213
+ async function captureView(args, context) {
2214
+ try {
2215
+ const raw = await context.engine.call("capture_view", args);
2216
+ const body = raw ?? {};
2217
+ const png = body.pngBase64;
2218
+ if (typeof png !== "string" || png.length === 0) {
2219
+ return {
2220
+ content: [{ type: "text", text: formatToolError(
2221
+ GP_E_INTERNAL,
2222
+ "capture_view answered without a pngBase64 payload",
2223
+ "retry once; if it repeats, report this \u2014 the engine captured but the shell could not read the image"
2224
+ ) }],
2225
+ isError: true
2226
+ };
2227
+ }
2228
+ if (body.persisted !== false) {
2229
+ return {
2230
+ content: [{ type: "text", text: formatToolError(
2231
+ GP_E_INTERNAL,
2232
+ `capture_view answered without persisted:false (got ${JSON.stringify(body.persisted ?? null)})`,
2233
+ "do not treat this image as unsaved: the engine did not say so. Retry once, then report the frame shape \u2014 the pass-through guarantee is a claim about the protocol, not a default the shell may fill in"
2234
+ ) }],
2235
+ isError: true
2236
+ };
2237
+ }
2238
+ const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2239
+ const summary = canonicalJson({
2240
+ mimeType,
2241
+ byteCount: body.byteCount,
2242
+ pixelWidth: body.pixelWidth,
2243
+ pixelHeight: body.pixelHeight,
2244
+ scale: body.scale,
2245
+ // 实际应用的倍率与请求值都给出:静默降采样失败时,"appliedScale" 才是
2246
+ // 模型该信的那个数(它决定自己看到的是不是缩过的版本)。
2247
+ appliedScale: body.appliedScale,
2248
+ windowId: body.windowId,
2249
+ pointSize: body.pointSize,
2250
+ persisted: body.persisted
2251
+ });
2252
+ return {
2253
+ content: [
2254
+ { type: "image", data: png, mimeType },
2255
+ { type: "text", text: summary }
2256
+ ],
2257
+ isError: false
2258
+ };
2259
+ } catch (error) {
2260
+ if (error instanceof EngineCallError) {
2261
+ return {
2262
+ content: [{ type: "text", text: formatToolErrorShape(error.toBody()) }],
2263
+ isError: true
2264
+ };
2265
+ }
2266
+ return {
2267
+ content: [{ type: "text", text: formatToolError(
2268
+ GP_E_INTERNAL,
2269
+ `internal shell error: ${String(error)}`,
2270
+ "see the MCP server logs and retry"
2271
+ ) }],
2272
+ isError: true
2273
+ };
2274
+ }
2275
+ }
2164
2276
  function mapEvidenceReadError(error) {
2165
2277
  if (error instanceof EvidenceFrameShapeError) {
2166
2278
  return {
@@ -2350,7 +2462,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
2350
2462
  "2025-03-26",
2351
2463
  MCP_PROTOCOL_VERSION
2352
2464
  ];
2353
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.1.1" };
2465
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.1.2" };
2354
2466
  var PARSE_ERROR = -32700;
2355
2467
  var INVALID_REQUEST = -32600;
2356
2468
  var METHOD_NOT_FOUND = -32601;
@@ -2499,8 +2611,23 @@ function parseArgs(argv) {
2499
2611
  var USAGE = `usage: glasspane-mcp [--socket-path <path>]
2500
2612
 
2501
2613
  Bridges an AI agent (MCP stdio) to the GlassPane engine daemon.
2502
- The daemon socket defaults to $HOME/.glasspane/engine.sock
2503
- (override with GLASSPANE_ENGINE_SOCK or --socket-path).
2614
+
2615
+ Socket, in the order this shell resolves it:
2616
+ 1. --socket-path <path>
2617
+ 2. GLASSPANE_ENGINE_SOCK=<path>
2618
+ 3. $HOME/.glasspane/engine.sock \u2014 a guess read from this process's HOME, not
2619
+ the daemon's rule: glasspaned's own default is the home folder the system
2620
+ reports, which a HOME you export does not move, and glasspaned --state-dir
2621
+ <dir> serves <dir>/daemon.sock instead.
2622
+
2623
+ So "nothing is listening on the default path" means this shell guessed a
2624
+ different directory, not that the daemon is dead. Connect to the socket the
2625
+ daemon is really on \u2014 read it, do not re-derive it:
2626
+ launchctl print gui/$(id -u)/com.glasspane.daemon # the installed job's --socket-path value
2627
+ glasspane-mcp --socket-path <that path> # or, equivalently:
2628
+ GLASSPANE_ENGINE_SOCK=<that path> glasspane-mcp
2629
+ A daemon started by hand with --state-dir has no launchd job to read: pass it
2630
+ <state-dir>/daemon.sock.
2504
2631
  `;
2505
2632
  var DRAIN_WAIT_MS = 3e4;
2506
2633
  function logNote(text) {
package/dist/tools.d.ts CHANGED
@@ -44,6 +44,25 @@ export declare const ObserveArgs: z.ZodObject<{
44
44
  maxDepth?: number | undefined;
45
45
  }>;
46
46
  export type ObserveArgs = z.infer<typeof ObserveArgs>;
47
+ export declare const CaptureViewArgs: z.ZodObject<{
48
+ scale: z.ZodOptional<z.ZodNumber>;
49
+ }, "strict", z.ZodTypeAny, {
50
+ scale?: number | undefined;
51
+ }, {
52
+ scale?: number | undefined;
53
+ }>;
54
+ export type CaptureViewArgs = z.infer<typeof CaptureViewArgs>;
55
+ export declare const AuditUiArgs: z.ZodObject<{
56
+ maxDepth: z.ZodOptional<z.ZodNumber>;
57
+ minHitTargetPt: z.ZodOptional<z.ZodNumber>;
58
+ }, "strict", z.ZodTypeAny, {
59
+ maxDepth?: number | undefined;
60
+ minHitTargetPt?: number | undefined;
61
+ }, {
62
+ maxDepth?: number | undefined;
63
+ minHitTargetPt?: number | undefined;
64
+ }>;
65
+ export type AuditUiArgs = z.infer<typeof AuditUiArgs>;
47
66
  export declare const ActArgs: z.ZodObject<{
48
67
  selector: z.ZodObject<{
49
68
  role: z.ZodString;
@@ -248,12 +267,23 @@ export interface ToolExecuteContext {
248
267
  */
249
268
  turn: TrailTurn;
250
269
  }
270
+ /**
271
+ * Content parts a tool may return. `image` exists for exactly one tool:
272
+ * `gp_capture_view`, whose whole point is handing one window image to the
273
+ * user's own model. It is a pass-through, not a store — the shell writes
274
+ * nothing to disk, so "no raw screenshot is persisted anywhere" stays true.
275
+ */
276
+ export type ToolContent = {
277
+ type: "text";
278
+ text: string;
279
+ } | {
280
+ type: "image";
281
+ data: string;
282
+ mimeType: string;
283
+ };
251
284
  /** Tool result shape shared by the default path and custom executors. */
252
285
  export interface ToolResult {
253
- content: Array<{
254
- type: "text";
255
- text: string;
256
- }>;
286
+ content: ToolContent[];
257
287
  isError: boolean;
258
288
  }
259
289
  export declare const TOOL_SPECS: readonly ToolSpec[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
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",