glasspane-mcp 1.6.2 → 1.7.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.6.2";
29
+ readonly version: "1.7.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -167,6 +167,7 @@ export declare function engineDeadlineMs(method: string, params?: Record<string,
167
167
  export declare function callerDeadlineMs(method: string, params?: Record<string, unknown>, ceilingMs?: number): number;
168
168
  /** Engine protocol version this shell speaks (daemon `hello.protocolVersion`). */
169
169
  export declare const ENGINE_PROTOCOL_VERSION = "0";
170
+ export declare const ENGINE_WRITE_BACKLOG_BYTES: number;
170
171
  /** Daemon self-report the shell compares a fresh connection against. */
171
172
  export interface EngineIdentityExpectation {
172
173
  /**
@@ -37,6 +37,19 @@ export declare function evidenceSummaryLines(pack: EvidencePackReportView): stri
37
37
  export declare function renderMarkdown(pack: EvidencePackReportView, diagnostics: string | undefined): string;
38
38
  /** Full HTML view with an operationId anchor; all values escaped. */
39
39
  export declare function renderHTML(pack: EvidencePackReportView, diagnostics: string | undefined): string;
40
+ /**
41
+ * 终端控制字符一律转成可见的 `\uXXXX`。
42
+ *
43
+ * markdown 这一路从前是原文输出的:`escapeHTML` 只护住了 HTML 那半,而
44
+ * `gp_export_evidence` 与报告导出默认交回的就是 markdown——代理会把它直接打到
45
+ * 终端上。报告里的标题、selector 标题、stateDiff 的键值、探针的文件路径全是
46
+ * app 自己写的字符串:一个窗口标题里塞一段 OSC-52 就能改写用户的剪贴板,
47
+ * 一段 ANSI 能把光标移走覆盖掉它说过的话。daemon 那一侧为这件事立过规矩
48
+ * (R5-03:`<0x20` 控制字符一律转义),这里补齐壳这一侧。
49
+ *
50
+ * `\t`(0x09) 与 `\n`(0x0a) 保留——它们是这份报告自己的排版,不是外来字符。
51
+ */
52
+ export declare function escapeTerminalControl(value: string): string;
40
53
  /**
41
54
  * Escape HTML metacharacters in values that cross into a report (spec v1.3
42
55
  * §13.4, the five-character escape set, no injection). Shared so aggregation
@@ -125,6 +125,9 @@ var StreamLineIo = class {
125
125
  writeLine(line) {
126
126
  this.output.write(line + "\n");
127
127
  }
128
+ bufferedBytes() {
129
+ return this.output.writableLength;
130
+ }
128
131
  onMessage(handler) {
129
132
  this.messageHandler = handler;
130
133
  }
@@ -233,6 +236,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
233
236
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
234
237
  }
235
238
  var ENGINE_PROTOCOL_VERSION = "0";
239
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
236
240
  var EngineCallError = class extends Error {
237
241
  code;
238
242
  message;
@@ -418,6 +422,16 @@ var EngineJsonRpcClient = class {
418
422
  fail: reject
419
423
  };
420
424
  entry.timer = setTimeout(() => this.onDeadline(entry), deadlineMs);
425
+ const backlog = this.io.bufferedBytes?.() ?? 0;
426
+ if (backlog > ENGINE_WRITE_BACKLOG_BYTES) {
427
+ this.pending.delete(id);
428
+ this.reject(entry, new EngineCallError(
429
+ GP_E_ENGINE_UNREACHABLE,
430
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
431
+ `the background service is accepting the connection but not reading it \u2014 it is wedged, not busy. Check it with gp_probe_status; if it stays like this, restart it with the command in \`glasspaned --help\` (or the panel's \u91CD\u542F\u540E\u53F0\u670D\u52A1 button). Do not retry this call in a loop: every retry only deepens the queue it is refusing to drain.`
432
+ ));
433
+ return;
434
+ }
421
435
  this.pending.set(id, entry);
422
436
  try {
423
437
  this.io.writeLine(JSON.stringify(frame));
@@ -474,13 +488,18 @@ var EngineJsonRpcClient = class {
474
488
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
475
489
  }
476
490
  handleMessage(line) {
477
- let frame;
491
+ let parsed;
478
492
  try {
479
- frame = JSON.parse(line);
493
+ parsed = JSON.parse(line);
480
494
  } catch {
481
495
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
482
496
  return;
483
497
  }
498
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
499
+ this.report(`engine sent a frame that is not a JSON object, dropped: ${truncate(line)}`);
500
+ return;
501
+ }
502
+ const frame = parsed;
484
503
  const id = frame.id;
485
504
  if (typeof id === "number") {
486
505
  const entry = this.pending.get(id);
@@ -525,9 +544,17 @@ var EngineJsonRpcClient = class {
525
544
  settleFromFrame(entry, frame) {
526
545
  if (frame.error) {
527
546
  this.reject(entry, this.engineError(frame.error));
528
- } else {
529
- this.resolve(entry, frame.result);
547
+ return;
548
+ }
549
+ if (!("result" in frame)) {
550
+ this.reject(entry, new EngineCallError(
551
+ GP_E_INTERNAL,
552
+ `engine answered '${entry.method}' with a frame carrying neither result nor error`,
553
+ "do not read this call as successful: the engine answered nothing. Retry once, then check the daemon log (stderr) for what it died before answering"
554
+ ));
555
+ return;
530
556
  }
557
+ this.resolve(entry, frame.result);
531
558
  }
532
559
  /**
533
560
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -754,6 +781,9 @@ var ReconnectingSocketIo = class {
754
781
  writeLine(line) {
755
782
  this.connect().writeLine(line);
756
783
  }
784
+ bufferedBytes() {
785
+ return this.inner?.bufferedBytes() ?? 0;
786
+ }
757
787
  onMessage(handler) {
758
788
  this.messageHandler = handler;
759
789
  }
@@ -893,7 +923,7 @@ function renderMarkdown(pack, diagnostics) {
893
923
  out.push(section.body);
894
924
  out.push("");
895
925
  }
896
- return out.join("\n");
926
+ return escapeTerminalControl(out.join("\n"));
897
927
  }
898
928
  function renderHTML(pack, diagnostics) {
899
929
  const out = [];
@@ -918,7 +948,10 @@ function renderHTML(pack, diagnostics) {
918
948
  out.push(`</section>`);
919
949
  }
920
950
  out.push(`</div>`);
921
- return out.join("\n");
951
+ return escapeTerminalControl(out.join("\n"));
952
+ }
953
+ function escapeTerminalControl(value) {
954
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
922
955
  }
923
956
  function escapeHTML(value) {
924
957
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -1245,7 +1278,11 @@ var RECIPE_MAX_STEPS = 64;
1245
1278
  var RecipeStepKindSchema = z3.enum(["act", "observe", "assert", "diagnose"]);
1246
1279
  var RecipeStepSchema = z3.strictObject({
1247
1280
  kind: RecipeStepKindSchema,
1248
- params: z3.record(z3.unknown())
1281
+ // Two-argument `z.record`: valid on the zod 3 this package pins *and* on zod 4,
1282
+ // which made the key type mandatory. The fork of opencode runs zod 4, and its
1283
+ // typecheck caught this as `TS2554: Expected 2-3 arguments, but got 1` — a
1284
+ // latent break waiting for the day this package's zod is bumped.
1285
+ params: z3.record(z3.string(), z3.unknown())
1249
1286
  });
1250
1287
  var RecipeConfigSchema = z3.strictObject({
1251
1288
  schemaVersion: z3.literal(RECIPE_SCHEMA_VERSION),
@@ -1281,8 +1318,14 @@ function parseEvidencePackRead(input) {
1281
1318
  throw schemaError("evidence pack", result.error);
1282
1319
  }
1283
1320
 
1321
+ // ../kernel/dist/decision-log.js
1322
+ var DecisionLogDraftSchema = DecisionLogEntrySchema.omit({
1323
+ sequence: true,
1324
+ prevEntryHash: true
1325
+ });
1326
+
1284
1327
  // src/canonical.ts
1285
- function canonicalJson(value) {
1328
+ function canonicalJson2(value) {
1286
1329
  return JSON.stringify(sortKeys(value));
1287
1330
  }
1288
1331
  function sortKeys(value) {
@@ -1502,6 +1545,27 @@ function tightenMode(target, want, kind) {
1502
1545
  );
1503
1546
  }
1504
1547
  }
1548
+ function fileIdentity(filePath) {
1549
+ try {
1550
+ const stat = fs2.statSync(filePath);
1551
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
1552
+ } catch (error) {
1553
+ if (nodeErrorCode(error) === "ENOENT") return null;
1554
+ throw error;
1555
+ }
1556
+ }
1557
+ function registryDriftReason(filePath, openedAs, now) {
1558
+ if (now === openedAs) {
1559
+ return null;
1560
+ }
1561
+ return `projects.json at ${filePath} changed between the read that produced this edit and the write that would have published it (was ${openedAs ?? "absent"}, now ${now ?? "absent"}), so this call wrote nothing. Another process \u2014 the background service, its CLI, or the settings panel \u2014 holds a newer version of this table, and renaming ours over theirs would drop their entry while reporting success. Repeat gp_project_set: it reads the file again, so the second attempt carries what the other writer landed. If it keeps failing, something is writing this registry on a timer \u2014 read the file to see what appeared, and do not force the write.`;
1562
+ }
1563
+ function assertNoConcurrentWrite(filePath, openedAs) {
1564
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1565
+ if (reason !== null) {
1566
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
1567
+ }
1568
+ }
1505
1569
  function saveProjects(filePath, entries) {
1506
1570
  const dir = path2.dirname(filePath);
1507
1571
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -1548,6 +1612,7 @@ function projectSet(args) {
1548
1612
  assertStorablePid(args.pid);
1549
1613
  const snapshot = loadRegistry(filePath);
1550
1614
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
1615
+ const openedAs = fileIdentity(filePath);
1551
1616
  let entry;
1552
1617
  const projectId = args.projectId;
1553
1618
  if (projectId) {
@@ -1573,6 +1638,7 @@ function projectSet(args) {
1573
1638
  assertSingleIdentity(entry);
1574
1639
  projects.push(entry);
1575
1640
  }
1641
+ assertNoConcurrentWrite(filePath, openedAs);
1576
1642
  saveProjects(filePath, projects);
1577
1643
  return {
1578
1644
  ...entry,
@@ -2364,7 +2430,7 @@ async function captureView(args, context) {
2364
2430
  };
2365
2431
  }
2366
2432
  const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2367
- const summary = canonicalJson({
2433
+ const summary = canonicalJson2({
2368
2434
  mimeType,
2369
2435
  byteCount: body.byteCount,
2370
2436
  pixelWidth: body.pixelWidth,
@@ -2428,7 +2494,7 @@ async function projectListTool() {
2428
2494
  try {
2429
2495
  const projects = projectList();
2430
2496
  return {
2431
- content: [{ type: "text", text: canonicalJson({ projects }) }],
2497
+ content: [{ type: "text", text: canonicalJson2({ projects }) }],
2432
2498
  isError: false
2433
2499
  };
2434
2500
  } catch (error) {
@@ -2438,7 +2504,7 @@ async function projectListTool() {
2438
2504
  async function projectSetTool(args) {
2439
2505
  try {
2440
2506
  const entry = projectSet(args);
2441
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2507
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2442
2508
  } catch (error) {
2443
2509
  return mapProjectError(error);
2444
2510
  }
@@ -2460,7 +2526,7 @@ async function projectGetTool(args) {
2460
2526
  isError: true
2461
2527
  };
2462
2528
  }
2463
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2529
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2464
2530
  } catch (error) {
2465
2531
  return mapProjectError(error);
2466
2532
  }
@@ -125,6 +125,9 @@ var StreamLineIo = class {
125
125
  writeLine(line) {
126
126
  this.output.write(line + "\n");
127
127
  }
128
+ bufferedBytes() {
129
+ return this.output.writableLength;
130
+ }
128
131
  onMessage(handler) {
129
132
  this.messageHandler = handler;
130
133
  }
@@ -229,6 +232,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
229
232
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
230
233
  }
231
234
  var ENGINE_PROTOCOL_VERSION = "0";
235
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
232
236
  var EngineCallError = class extends Error {
233
237
  code;
234
238
  message;
@@ -414,6 +418,16 @@ var EngineJsonRpcClient = class {
414
418
  fail: reject
415
419
  };
416
420
  entry.timer = setTimeout(() => this.onDeadline(entry), deadlineMs);
421
+ const backlog = this.io.bufferedBytes?.() ?? 0;
422
+ if (backlog > ENGINE_WRITE_BACKLOG_BYTES) {
423
+ this.pending.delete(id);
424
+ this.reject(entry, new EngineCallError(
425
+ GP_E_ENGINE_UNREACHABLE,
426
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
427
+ `the background service is accepting the connection but not reading it \u2014 it is wedged, not busy. Check it with gp_probe_status; if it stays like this, restart it with the command in \`glasspaned --help\` (or the panel's \u91CD\u542F\u540E\u53F0\u670D\u52A1 button). Do not retry this call in a loop: every retry only deepens the queue it is refusing to drain.`
428
+ ));
429
+ return;
430
+ }
417
431
  this.pending.set(id, entry);
418
432
  try {
419
433
  this.io.writeLine(JSON.stringify(frame));
@@ -470,13 +484,18 @@ var EngineJsonRpcClient = class {
470
484
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
471
485
  }
472
486
  handleMessage(line) {
473
- let frame;
487
+ let parsed;
474
488
  try {
475
- frame = JSON.parse(line);
489
+ parsed = JSON.parse(line);
476
490
  } catch {
477
491
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
478
492
  return;
479
493
  }
494
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
495
+ this.report(`engine sent a frame that is not a JSON object, dropped: ${truncate(line)}`);
496
+ return;
497
+ }
498
+ const frame = parsed;
480
499
  const id = frame.id;
481
500
  if (typeof id === "number") {
482
501
  const entry = this.pending.get(id);
@@ -521,9 +540,17 @@ var EngineJsonRpcClient = class {
521
540
  settleFromFrame(entry, frame) {
522
541
  if (frame.error) {
523
542
  this.reject(entry, this.engineError(frame.error));
524
- } else {
525
- this.resolve(entry, frame.result);
543
+ return;
544
+ }
545
+ if (!("result" in frame)) {
546
+ this.reject(entry, new EngineCallError(
547
+ GP_E_INTERNAL,
548
+ `engine answered '${entry.method}' with a frame carrying neither result nor error`,
549
+ "do not read this call as successful: the engine answered nothing. Retry once, then check the daemon log (stderr) for what it died before answering"
550
+ ));
551
+ return;
526
552
  }
553
+ this.resolve(entry, frame.result);
527
554
  }
528
555
  /**
529
556
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -750,6 +777,9 @@ var ReconnectingSocketIo = class {
750
777
  writeLine(line) {
751
778
  this.connect().writeLine(line);
752
779
  }
780
+ bufferedBytes() {
781
+ return this.inner?.bufferedBytes() ?? 0;
782
+ }
753
783
  onMessage(handler) {
754
784
  this.messageHandler = handler;
755
785
  }
@@ -883,7 +913,7 @@ function renderMarkdown(pack, diagnostics) {
883
913
  out.push(section.body);
884
914
  out.push("");
885
915
  }
886
- return out.join("\n");
916
+ return escapeTerminalControl(out.join("\n"));
887
917
  }
888
918
  function renderHTML(pack, diagnostics) {
889
919
  const out = [];
@@ -908,7 +938,10 @@ function renderHTML(pack, diagnostics) {
908
938
  out.push(`</section>`);
909
939
  }
910
940
  out.push(`</div>`);
911
- return out.join("\n");
941
+ return escapeTerminalControl(out.join("\n"));
942
+ }
943
+ function escapeTerminalControl(value) {
944
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
912
945
  }
913
946
  function escapeHTML(value) {
914
947
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -1235,7 +1268,11 @@ var RECIPE_MAX_STEPS = 64;
1235
1268
  var RecipeStepKindSchema = z3.enum(["act", "observe", "assert", "diagnose"]);
1236
1269
  var RecipeStepSchema = z3.strictObject({
1237
1270
  kind: RecipeStepKindSchema,
1238
- params: z3.record(z3.unknown())
1271
+ // Two-argument `z.record`: valid on the zod 3 this package pins *and* on zod 4,
1272
+ // which made the key type mandatory. The fork of opencode runs zod 4, and its
1273
+ // typecheck caught this as `TS2554: Expected 2-3 arguments, but got 1` — a
1274
+ // latent break waiting for the day this package's zod is bumped.
1275
+ params: z3.record(z3.string(), z3.unknown())
1239
1276
  });
1240
1277
  var RecipeConfigSchema = z3.strictObject({
1241
1278
  schemaVersion: z3.literal(RECIPE_SCHEMA_VERSION),
@@ -1271,8 +1308,14 @@ function parseEvidencePackRead(input) {
1271
1308
  throw schemaError("evidence pack", result.error);
1272
1309
  }
1273
1310
 
1311
+ // ../kernel/dist/decision-log.js
1312
+ var DecisionLogDraftSchema = DecisionLogEntrySchema.omit({
1313
+ sequence: true,
1314
+ prevEntryHash: true
1315
+ });
1316
+
1274
1317
  // src/canonical.ts
1275
- function canonicalJson(value) {
1318
+ function canonicalJson2(value) {
1276
1319
  return JSON.stringify(sortKeys(value));
1277
1320
  }
1278
1321
  function sortKeys(value) {
@@ -1492,6 +1535,27 @@ function tightenMode(target, want, kind) {
1492
1535
  );
1493
1536
  }
1494
1537
  }
1538
+ function fileIdentity(filePath) {
1539
+ try {
1540
+ const stat = fs2.statSync(filePath);
1541
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
1542
+ } catch (error) {
1543
+ if (nodeErrorCode(error) === "ENOENT") return null;
1544
+ throw error;
1545
+ }
1546
+ }
1547
+ function registryDriftReason(filePath, openedAs, now) {
1548
+ if (now === openedAs) {
1549
+ return null;
1550
+ }
1551
+ return `projects.json at ${filePath} changed between the read that produced this edit and the write that would have published it (was ${openedAs ?? "absent"}, now ${now ?? "absent"}), so this call wrote nothing. Another process \u2014 the background service, its CLI, or the settings panel \u2014 holds a newer version of this table, and renaming ours over theirs would drop their entry while reporting success. Repeat gp_project_set: it reads the file again, so the second attempt carries what the other writer landed. If it keeps failing, something is writing this registry on a timer \u2014 read the file to see what appeared, and do not force the write.`;
1552
+ }
1553
+ function assertNoConcurrentWrite(filePath, openedAs) {
1554
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1555
+ if (reason !== null) {
1556
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
1557
+ }
1558
+ }
1495
1559
  function saveProjects(filePath, entries) {
1496
1560
  const dir = path2.dirname(filePath);
1497
1561
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -1538,6 +1602,7 @@ function projectSet(args) {
1538
1602
  assertStorablePid(args.pid);
1539
1603
  const snapshot = loadRegistry(filePath);
1540
1604
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
1605
+ const openedAs = fileIdentity(filePath);
1541
1606
  let entry;
1542
1607
  const projectId = args.projectId;
1543
1608
  if (projectId) {
@@ -1563,6 +1628,7 @@ function projectSet(args) {
1563
1628
  assertSingleIdentity(entry);
1564
1629
  projects.push(entry);
1565
1630
  }
1631
+ assertNoConcurrentWrite(filePath, openedAs);
1566
1632
  saveProjects(filePath, projects);
1567
1633
  return {
1568
1634
  ...entry,
@@ -2354,7 +2420,7 @@ async function captureView(args, context) {
2354
2420
  };
2355
2421
  }
2356
2422
  const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2357
- const summary = canonicalJson({
2423
+ const summary = canonicalJson2({
2358
2424
  mimeType,
2359
2425
  byteCount: body.byteCount,
2360
2426
  pixelWidth: body.pixelWidth,
@@ -2418,7 +2484,7 @@ async function projectListTool() {
2418
2484
  try {
2419
2485
  const projects = projectList();
2420
2486
  return {
2421
- content: [{ type: "text", text: canonicalJson({ projects }) }],
2487
+ content: [{ type: "text", text: canonicalJson2({ projects }) }],
2422
2488
  isError: false
2423
2489
  };
2424
2490
  } catch (error) {
@@ -2428,7 +2494,7 @@ async function projectListTool() {
2428
2494
  async function projectSetTool(args) {
2429
2495
  try {
2430
2496
  const entry = projectSet(args);
2431
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2497
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2432
2498
  } catch (error) {
2433
2499
  return mapProjectError(error);
2434
2500
  }
@@ -2450,7 +2516,7 @@ async function projectGetTool(args) {
2450
2516
  isError: true
2451
2517
  };
2452
2518
  }
2453
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2519
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2454
2520
  } catch (error) {
2455
2521
  return mapProjectError(error);
2456
2522
  }
package/dist/index.js CHANGED
@@ -229,7 +229,11 @@ var RECIPE_MAX_STEPS = 64;
229
229
  var RecipeStepKindSchema = z3.enum(["act", "observe", "assert", "diagnose"]);
230
230
  var RecipeStepSchema = z3.strictObject({
231
231
  kind: RecipeStepKindSchema,
232
- params: z3.record(z3.unknown())
232
+ // Two-argument `z.record`: valid on the zod 3 this package pins *and* on zod 4,
233
+ // which made the key type mandatory. The fork of opencode runs zod 4, and its
234
+ // typecheck caught this as `TS2554: Expected 2-3 arguments, but got 1` — a
235
+ // latent break waiting for the day this package's zod is bumped.
236
+ params: z3.record(z3.string(), z3.unknown())
233
237
  });
234
238
  var RecipeConfigSchema = z3.strictObject({
235
239
  schemaVersion: z3.literal(RECIPE_SCHEMA_VERSION),
@@ -265,6 +269,12 @@ function parseEvidencePackRead(input) {
265
269
  throw schemaError("evidence pack", result.error);
266
270
  }
267
271
 
272
+ // ../kernel/dist/decision-log.js
273
+ var DecisionLogDraftSchema = DecisionLogEntrySchema.omit({
274
+ sequence: true,
275
+ prevEntryHash: true
276
+ });
277
+
268
278
  // src/update-state.ts
269
279
  import fs from "node:fs";
270
280
  import os from "node:os";
@@ -530,11 +540,16 @@ function readUpdateState(env = process.env, stateRootOverride) {
530
540
  `current=${String(parsed.current ?? "unknown")}`,
531
541
  `latest=${String(parsed.latest ?? "none-seen")}`,
532
542
  `lastCheckAt=${String(parsed.updatedAt ?? "never")}`,
533
- `autoUpdate=${parsed.disabled === true ? "off" : parsed.autoApply === true ? "on" : "on-by-default"}`
543
+ // `disabled` 缺失与 `disabled: false` 不是一件事:前者是"这份记录没说",后者是
544
+ // "记录说了:没关"。从前两者都掉进 `on-by-default`,等于替这台机器宣布自动更新
545
+ // 开着——而本文件开头立的规矩恰恰是 absent 不许读成友好态。
546
+ `autoUpdate=${parsed.disabled === true ? "off" : parsed.disabled === false ? "on" : parsed.autoApply === true ? "on" : "unknown(neither disabled nor autoApply is recorded)"}`
534
547
  ];
535
548
  const lastError = parsed.lastError;
536
- if (lastError && typeof lastError.message === "string") {
537
- bits.push(`lastError=${lastError.code ?? "no-code"}: ${lastError.message}`);
549
+ if (lastError && (typeof lastError.code === "string" || typeof lastError.message === "string")) {
550
+ const code = typeof lastError.code === "string" && lastError.code !== "" ? lastError.code : "no-code";
551
+ const message = typeof lastError.message === "string" && lastError.message !== "" ? lastError.message : "(the record carries a code with no message: the code is all this shell can honestly say)";
552
+ bits.push(`lastError=${code}: ${message}`);
538
553
  }
539
554
  const staged = parsed.staged;
540
555
  if (staged && typeof staged.version === "string") {
@@ -658,6 +673,9 @@ var StreamLineIo = class {
658
673
  writeLine(line) {
659
674
  this.output.write(line + "\n");
660
675
  }
676
+ bufferedBytes() {
677
+ return this.output.writableLength;
678
+ }
661
679
  onMessage(handler) {
662
680
  this.messageHandler = handler;
663
681
  }
@@ -874,6 +892,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
874
892
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
875
893
  }
876
894
  var ENGINE_PROTOCOL_VERSION = "0";
895
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
877
896
  var EngineCallError = class extends Error {
878
897
  code;
879
898
  message;
@@ -1059,6 +1078,16 @@ var EngineJsonRpcClient = class {
1059
1078
  fail: reject
1060
1079
  };
1061
1080
  entry.timer = setTimeout(() => this.onDeadline(entry), deadlineMs);
1081
+ const backlog = this.io.bufferedBytes?.() ?? 0;
1082
+ if (backlog > ENGINE_WRITE_BACKLOG_BYTES) {
1083
+ this.pending.delete(id);
1084
+ this.reject(entry, new EngineCallError(
1085
+ GP_E_ENGINE_UNREACHABLE,
1086
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
1087
+ `the background service is accepting the connection but not reading it \u2014 it is wedged, not busy. Check it with gp_probe_status; if it stays like this, restart it with the command in \`glasspaned --help\` (or the panel's \u91CD\u542F\u540E\u53F0\u670D\u52A1 button). Do not retry this call in a loop: every retry only deepens the queue it is refusing to drain.`
1088
+ ));
1089
+ return;
1090
+ }
1062
1091
  this.pending.set(id, entry);
1063
1092
  try {
1064
1093
  this.io.writeLine(JSON.stringify(frame));
@@ -1115,13 +1144,18 @@ var EngineJsonRpcClient = class {
1115
1144
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
1116
1145
  }
1117
1146
  handleMessage(line) {
1118
- let frame;
1147
+ let parsed;
1119
1148
  try {
1120
- frame = JSON.parse(line);
1149
+ parsed = JSON.parse(line);
1121
1150
  } catch {
1122
1151
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
1123
1152
  return;
1124
1153
  }
1154
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1155
+ this.report(`engine sent a frame that is not a JSON object, dropped: ${truncate(line)}`);
1156
+ return;
1157
+ }
1158
+ const frame = parsed;
1125
1159
  const id = frame.id;
1126
1160
  if (typeof id === "number") {
1127
1161
  const entry = this.pending.get(id);
@@ -1166,9 +1200,17 @@ var EngineJsonRpcClient = class {
1166
1200
  settleFromFrame(entry, frame) {
1167
1201
  if (frame.error) {
1168
1202
  this.reject(entry, this.engineError(frame.error));
1169
- } else {
1170
- this.resolve(entry, frame.result);
1203
+ return;
1204
+ }
1205
+ if (!("result" in frame)) {
1206
+ this.reject(entry, new EngineCallError(
1207
+ GP_E_INTERNAL,
1208
+ `engine answered '${entry.method}' with a frame carrying neither result nor error`,
1209
+ "do not read this call as successful: the engine answered nothing. Retry once, then check the daemon log (stderr) for what it died before answering"
1210
+ ));
1211
+ return;
1171
1212
  }
1213
+ this.resolve(entry, frame.result);
1172
1214
  }
1173
1215
  /**
1174
1216
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -1395,6 +1437,9 @@ var ReconnectingSocketIo = class {
1395
1437
  writeLine(line) {
1396
1438
  this.connect().writeLine(line);
1397
1439
  }
1440
+ bufferedBytes() {
1441
+ return this.inner?.bufferedBytes() ?? 0;
1442
+ }
1398
1443
  onMessage(handler) {
1399
1444
  this.messageHandler = handler;
1400
1445
  }
@@ -1765,7 +1810,7 @@ function renderMarkdown(pack, diagnostics) {
1765
1810
  out.push(section.body);
1766
1811
  out.push("");
1767
1812
  }
1768
- return out.join("\n");
1813
+ return escapeTerminalControl(out.join("\n"));
1769
1814
  }
1770
1815
  function renderHTML(pack, diagnostics) {
1771
1816
  const out = [];
@@ -1790,7 +1835,10 @@ function renderHTML(pack, diagnostics) {
1790
1835
  out.push(`</section>`);
1791
1836
  }
1792
1837
  out.push(`</div>`);
1793
- return out.join("\n");
1838
+ return escapeTerminalControl(out.join("\n"));
1839
+ }
1840
+ function escapeTerminalControl(value) {
1841
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
1794
1842
  }
1795
1843
  function escapeHTML(value) {
1796
1844
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -2106,6 +2154,27 @@ function tightenMode(target, want, kind) {
2106
2154
  );
2107
2155
  }
2108
2156
  }
2157
+ function fileIdentity(filePath) {
2158
+ try {
2159
+ const stat = fs3.statSync(filePath);
2160
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
2161
+ } catch (error) {
2162
+ if (nodeErrorCode(error) === "ENOENT") return null;
2163
+ throw error;
2164
+ }
2165
+ }
2166
+ function registryDriftReason(filePath, openedAs, now) {
2167
+ if (now === openedAs) {
2168
+ return null;
2169
+ }
2170
+ return `projects.json at ${filePath} changed between the read that produced this edit and the write that would have published it (was ${openedAs ?? "absent"}, now ${now ?? "absent"}), so this call wrote nothing. Another process \u2014 the background service, its CLI, or the settings panel \u2014 holds a newer version of this table, and renaming ours over theirs would drop their entry while reporting success. Repeat gp_project_set: it reads the file again, so the second attempt carries what the other writer landed. If it keeps failing, something is writing this registry on a timer \u2014 read the file to see what appeared, and do not force the write.`;
2171
+ }
2172
+ function assertNoConcurrentWrite(filePath, openedAs) {
2173
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
2174
+ if (reason !== null) {
2175
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
2176
+ }
2177
+ }
2109
2178
  function saveProjects(filePath, entries) {
2110
2179
  const dir = path3.dirname(filePath);
2111
2180
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -2152,6 +2221,7 @@ function projectSet(args) {
2152
2221
  assertStorablePid(args.pid);
2153
2222
  const snapshot = loadRegistry(filePath);
2154
2223
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
2224
+ const openedAs = fileIdentity(filePath);
2155
2225
  let entry;
2156
2226
  const projectId = args.projectId;
2157
2227
  if (projectId) {
@@ -2177,6 +2247,7 @@ function projectSet(args) {
2177
2247
  assertSingleIdentity(entry);
2178
2248
  projects.push(entry);
2179
2249
  }
2250
+ assertNoConcurrentWrite(filePath, openedAs);
2180
2251
  saveProjects(filePath, projects);
2181
2252
  return {
2182
2253
  ...entry,
@@ -3590,7 +3661,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3590
3661
  "2025-03-26",
3591
3662
  MCP_PROTOCOL_VERSION
3592
3663
  ];
3593
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.6.2" };
3664
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.7.0" };
3594
3665
  var PARSE_ERROR = -32700;
3595
3666
  var INVALID_REQUEST = -32600;
3596
3667
  var METHOD_NOT_FOUND = -32601;
@@ -3793,6 +3864,12 @@ function parseArgs(argv) {
3793
3864
  i += 1;
3794
3865
  } else if (arg.startsWith("--socket-path=")) {
3795
3866
  options.socketPath = arg.slice("--socket-path=".length);
3867
+ } else {
3868
+ throw new Error(
3869
+ `unknown argument: ${arg}
3870
+ ${USAGE}
3871
+ This shell accepts only --help/-h and --socket-path/-s <path>. A flag spelled wrong here is not an error the daemon will ever report.`
3872
+ );
3796
3873
  }
3797
3874
  }
3798
3875
  return options;
package/dist/io.d.ts CHANGED
@@ -62,6 +62,16 @@ export declare function recoverFrameId(prefix: string): number | string | null;
62
62
  */
63
63
  export interface LineIo {
64
64
  writeLine(line: string): void;
65
+ /**
66
+ * 已经交给内核、还没被对端读走的字节数。可选:只有真的接在一条 socket/pipe 上
67
+ * 的传输量得出这个数。
68
+ *
69
+ * 它存在的理由是 `writeLine` 不看 `output.write()` 的返回值(stdio 那一侧有
70
+ * `DrainAwareWriter` 管背压,引擎这一侧从前没有):daemon 停止读 socket 时,
71
+ * Node 会把写进去的帧无上限地堆在进程内存里,调用方只看到"还没回",
72
+ * 而这一面已经悄悄替它攒了几百 MB。有了这个数,客户端可以选择**不再接活**。
73
+ */
74
+ bufferedBytes?(): number;
65
75
  onMessage(handler: (line: string) => void): void;
66
76
  onClose(handler: () => void): void;
67
77
  onError(handler: (error: Error) => void): void;
@@ -86,6 +96,7 @@ export declare class StreamLineIo implements LineIo {
86
96
  private errorHandler;
87
97
  constructor(input: Readable, output: Writable);
88
98
  writeLine(line: string): void;
99
+ bufferedBytes(): number;
89
100
  onMessage(handler: (line: string) => void): void;
90
101
  onClose(handler: () => void): void;
91
102
  onError(handler: (error: Error) => void): void;
@@ -112,6 +112,13 @@ export declare const ProjectSetArgs: z.ZodObject<{
112
112
  pid?: number | null | undefined;
113
113
  }>;
114
114
  export type ProjectSetArgs = z.infer<typeof ProjectSetArgs>;
115
+ /**
116
+ * 读—改—写之间盘上换了哪一份:给出这一句拒绝的理由,没换则 null。
117
+ *
118
+ * 单独抽成纯函数是为了能被验红——`projectSet` 是同步的,测试没法在它读到盘与
119
+ * 写回盘之间插进另一次写,所以"守卫真的会拦"这件事只能把判据本身拿出来测。
120
+ */
121
+ export declare function registryDriftReason(filePath: string, openedAs: string | null, now: string | null): string | null;
115
122
  export declare function projectList(): ProjectEntry[];
116
123
  export declare function projectGet(projectId: string): ProjectEntry | undefined;
117
124
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.7.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",