glasspane-mcp 1.6.2 → 1.8.1

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.8.1";
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
  /**
@@ -242,6 +243,15 @@ export declare function livenessProbeDecision(outcome: LivenessProbeOutcome): st
242
243
  export type ShellSurface = "mcp" | "http";
243
244
  /** Where the answer to a timed-out request can be found, per surface. */
244
245
  export declare function lateReplyRoute(surface: ShellSurface): string;
246
+ /**
247
+ * 背压闸的出路。两条禁令各由一条现场证据撑着:探针本身也要过一次 `call()`,会被
248
+ * 同一道测量原地拒掉,所以旧文本那句"先用 gp_probe_status 查一下"是一件此刻做不到
249
+ * 第二次的事;这里唯一测到的是**本进程**的字节水位,不是 daemon 的状态,而旧文本
250
+ * 却挂了 GP_E_ENGINE_UNREACHABLE 那条重启命令——`livenessProbeDecision` 把点名生命
251
+ * 周期命令的权力只留给 `unreachable`,换一个可能正跑着 act 的 service 会回滚用户屏
252
+ * 幕上的动作。"每次重试都只会把队列堆得更深"同样不成立:拒绝发生在 `writeLine` 之前。
253
+ */
254
+ export declare function writeBacklogRemedy(backlogBytes: number, limitBytes: number): string;
245
255
  /**
246
256
  * Remedy for a deadline overrun: wait and measure, never restart on a probe that
247
257
  * merely went unanswered, and never replay a request whose re-issue has a side
package/dist/errors.d.ts CHANGED
@@ -32,6 +32,20 @@ export declare const GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
32
32
  */
33
33
  export declare const GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
34
34
  export declare const GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
35
+ /**
36
+ * Shell-only: this process refused to *write* a request, because the bytes it had
37
+ * already handed the daemon's socket were not being read
38
+ * (`ENGINE_WRITE_BACKLOG_BYTES` in `engine-client.ts`).
39
+ *
40
+ * It is deliberately not `GP_E_ENGINE_UNREACHABLE`, which is what this refusal
41
+ * used to answer with: that code's remedy orders a daemon restart, and a backlog
42
+ * measures a memory watermark in *this* process, not the service's state — the
43
+ * daemon may be mid-`act` on the user's screen, where a SIGTERM cancels and rolls
44
+ * the action back. The two answers also have different next moves, which is the
45
+ * real reason to split the code: the unread bytes may drain on their own, while a
46
+ * socket nobody is listening on never does.
47
+ */
48
+ export declare const GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
35
49
  /**
36
50
  * Shell-only, and deliberately *not* a daemon code: this is what the shell
37
51
  * answers when a daemon error frame arrives with no `code` at all. Naming it
@@ -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
  }
@@ -148,6 +151,7 @@ var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
148
151
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
149
152
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
150
153
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
154
+ var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
151
155
  var GP_E_UNKNOWN = "GP_E_UNKNOWN";
152
156
  var GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
153
157
  function formatToolError(code, message, remedy) {
@@ -233,6 +237,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
233
237
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
234
238
  }
235
239
  var ENGINE_PROTOCOL_VERSION = "0";
240
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
236
241
  var EngineCallError = class extends Error {
237
242
  code;
238
243
  message;
@@ -276,6 +281,9 @@ var NARROWING_AUTHORITY = {
276
281
  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",
277
282
  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"
278
283
  };
284
+ function writeBacklogRemedy(backlogBytes, limitBytes) {
285
+ return `this shell wrote nothing: ${backlogBytes} bytes \u2014 over this gate's ${limitBytes} \u2014 are queued in THIS process for the engine socket, and the service accepted the connection and is not reading it. That is a fact about this shell's buffer, not a diagnosis of why: one long operation and a service wedged solid look identical from here, which is why this answer orders neither a restart nor a probe. Stop sending. Do not retry this call in a loop: every attempt is refused by this same measurement, and a refusal writes no bytes, so a loop moves only the clock, not the queue. What is already queued is the service's to read, so wait for the backlog to drain and send again after that \u2014 this gate is re-measured per call, so the first call accepted after the wait is the proof that it drained, and it is a first send rather than a second copy of an action, because nothing went out the first time. While waiting, read the daemon's own log: it is the only side that can say what it is stuck on, and this shell cannot see past its own buffer. For the service installed by this repo's installer that log is \`~/.glasspane/installer-daemon.log\`, the file its stderr is redirected into, in the same directory as the default socket; a daemon started by hand writes wherever its operator pointed its stderr.`;
286
+ }
279
287
  function clientClosingRemedy(surface) {
280
288
  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
289
  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.`;
@@ -418,6 +426,16 @@ var EngineJsonRpcClient = class {
418
426
  fail: reject
419
427
  };
420
428
  entry.timer = setTimeout(() => this.onDeadline(entry), deadlineMs);
429
+ const backlog = this.io.bufferedBytes?.() ?? 0;
430
+ if (backlog > ENGINE_WRITE_BACKLOG_BYTES) {
431
+ this.pending.delete(id);
432
+ this.reject(entry, new EngineCallError(
433
+ GP_E_ENGINE_BACKLOG,
434
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
435
+ writeBacklogRemedy(backlog, ENGINE_WRITE_BACKLOG_BYTES)
436
+ ));
437
+ return;
438
+ }
421
439
  this.pending.set(id, entry);
422
440
  try {
423
441
  this.io.writeLine(JSON.stringify(frame));
@@ -474,13 +492,18 @@ var EngineJsonRpcClient = class {
474
492
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
475
493
  }
476
494
  handleMessage(line) {
477
- let frame;
495
+ let parsed;
478
496
  try {
479
- frame = JSON.parse(line);
497
+ parsed = JSON.parse(line);
480
498
  } catch {
481
499
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
482
500
  return;
483
501
  }
502
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
503
+ this.report(`engine sent a frame that is not a JSON object, dropped: ${truncate(line)}`);
504
+ return;
505
+ }
506
+ const frame = parsed;
484
507
  const id = frame.id;
485
508
  if (typeof id === "number") {
486
509
  const entry = this.pending.get(id);
@@ -525,9 +548,17 @@ var EngineJsonRpcClient = class {
525
548
  settleFromFrame(entry, frame) {
526
549
  if (frame.error) {
527
550
  this.reject(entry, this.engineError(frame.error));
528
- } else {
529
- this.resolve(entry, frame.result);
551
+ return;
552
+ }
553
+ if (!("result" in frame)) {
554
+ this.reject(entry, new EngineCallError(
555
+ GP_E_INTERNAL,
556
+ `engine answered '${entry.method}' with a frame carrying neither result nor error`,
557
+ "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"
558
+ ));
559
+ return;
530
560
  }
561
+ this.resolve(entry, frame.result);
531
562
  }
532
563
  /**
533
564
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -754,6 +785,9 @@ var ReconnectingSocketIo = class {
754
785
  writeLine(line) {
755
786
  this.connect().writeLine(line);
756
787
  }
788
+ bufferedBytes() {
789
+ return this.inner?.bufferedBytes() ?? 0;
790
+ }
757
791
  onMessage(handler) {
758
792
  this.messageHandler = handler;
759
793
  }
@@ -893,7 +927,7 @@ function renderMarkdown(pack, diagnostics) {
893
927
  out.push(section.body);
894
928
  out.push("");
895
929
  }
896
- return out.join("\n");
930
+ return escapeTerminalControl(out.join("\n"));
897
931
  }
898
932
  function renderHTML(pack, diagnostics) {
899
933
  const out = [];
@@ -918,7 +952,10 @@ function renderHTML(pack, diagnostics) {
918
952
  out.push(`</section>`);
919
953
  }
920
954
  out.push(`</div>`);
921
- return out.join("\n");
955
+ return escapeTerminalControl(out.join("\n"));
956
+ }
957
+ function escapeTerminalControl(value) {
958
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
922
959
  }
923
960
  function escapeHTML(value) {
924
961
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -1245,7 +1282,11 @@ var RECIPE_MAX_STEPS = 64;
1245
1282
  var RecipeStepKindSchema = z3.enum(["act", "observe", "assert", "diagnose"]);
1246
1283
  var RecipeStepSchema = z3.strictObject({
1247
1284
  kind: RecipeStepKindSchema,
1248
- params: z3.record(z3.unknown())
1285
+ // Two-argument `z.record`: valid on the zod 3 this package pins *and* on zod 4,
1286
+ // which made the key type mandatory. The fork of opencode runs zod 4, and its
1287
+ // typecheck caught this as `TS2554: Expected 2-3 arguments, but got 1` — a
1288
+ // latent break waiting for the day this package's zod is bumped.
1289
+ params: z3.record(z3.string(), z3.unknown())
1249
1290
  });
1250
1291
  var RecipeConfigSchema = z3.strictObject({
1251
1292
  schemaVersion: z3.literal(RECIPE_SCHEMA_VERSION),
@@ -1281,8 +1322,14 @@ function parseEvidencePackRead(input) {
1281
1322
  throw schemaError("evidence pack", result.error);
1282
1323
  }
1283
1324
 
1325
+ // ../kernel/dist/decision-log.js
1326
+ var DecisionLogDraftSchema = DecisionLogEntrySchema.omit({
1327
+ sequence: true,
1328
+ prevEntryHash: true
1329
+ });
1330
+
1284
1331
  // src/canonical.ts
1285
- function canonicalJson(value) {
1332
+ function canonicalJson2(value) {
1286
1333
  return JSON.stringify(sortKeys(value));
1287
1334
  }
1288
1335
  function sortKeys(value) {
@@ -1502,6 +1549,27 @@ function tightenMode(target, want, kind) {
1502
1549
  );
1503
1550
  }
1504
1551
  }
1552
+ function fileIdentity(filePath) {
1553
+ try {
1554
+ const stat = fs2.statSync(filePath);
1555
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
1556
+ } catch (error) {
1557
+ if (nodeErrorCode(error) === "ENOENT") return null;
1558
+ throw error;
1559
+ }
1560
+ }
1561
+ function registryDriftReason(filePath, openedAs, now) {
1562
+ if (now === openedAs) {
1563
+ return null;
1564
+ }
1565
+ 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.`;
1566
+ }
1567
+ function assertNoConcurrentWrite(filePath, openedAs) {
1568
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1569
+ if (reason !== null) {
1570
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
1571
+ }
1572
+ }
1505
1573
  function saveProjects(filePath, entries) {
1506
1574
  const dir = path2.dirname(filePath);
1507
1575
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -1548,6 +1616,7 @@ function projectSet(args) {
1548
1616
  assertStorablePid(args.pid);
1549
1617
  const snapshot = loadRegistry(filePath);
1550
1618
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
1619
+ const openedAs = fileIdentity(filePath);
1551
1620
  let entry;
1552
1621
  const projectId = args.projectId;
1553
1622
  if (projectId) {
@@ -1573,6 +1642,7 @@ function projectSet(args) {
1573
1642
  assertSingleIdentity(entry);
1574
1643
  projects.push(entry);
1575
1644
  }
1645
+ assertNoConcurrentWrite(filePath, openedAs);
1576
1646
  saveProjects(filePath, projects);
1577
1647
  return {
1578
1648
  ...entry,
@@ -2364,7 +2434,7 @@ async function captureView(args, context) {
2364
2434
  };
2365
2435
  }
2366
2436
  const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2367
- const summary = canonicalJson({
2437
+ const summary = canonicalJson2({
2368
2438
  mimeType,
2369
2439
  byteCount: body.byteCount,
2370
2440
  pixelWidth: body.pixelWidth,
@@ -2428,7 +2498,7 @@ async function projectListTool() {
2428
2498
  try {
2429
2499
  const projects = projectList();
2430
2500
  return {
2431
- content: [{ type: "text", text: canonicalJson({ projects }) }],
2501
+ content: [{ type: "text", text: canonicalJson2({ projects }) }],
2432
2502
  isError: false
2433
2503
  };
2434
2504
  } catch (error) {
@@ -2438,7 +2508,7 @@ async function projectListTool() {
2438
2508
  async function projectSetTool(args) {
2439
2509
  try {
2440
2510
  const entry = projectSet(args);
2441
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2511
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2442
2512
  } catch (error) {
2443
2513
  return mapProjectError(error);
2444
2514
  }
@@ -2460,7 +2530,7 @@ async function projectGetTool(args) {
2460
2530
  isError: true
2461
2531
  };
2462
2532
  }
2463
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2533
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2464
2534
  } catch (error) {
2465
2535
  return mapProjectError(error);
2466
2536
  }
@@ -3324,6 +3394,8 @@ function httpStatusForEngineCode(code) {
3324
3394
  return 409;
3325
3395
  case GP_E_ENGINE_UNREACHABLE:
3326
3396
  return 503;
3397
+ case GP_E_ENGINE_BACKLOG:
3398
+ return 503;
3327
3399
  case GP_E_ENGINE_TIMEOUT:
3328
3400
  return 504;
3329
3401
  case GP_E_PAYLOAD_TOO_LARGE:
@@ -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
  }
@@ -148,6 +151,7 @@ var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
148
151
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
149
152
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
150
153
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
154
+ var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
151
155
  var GP_E_UNKNOWN = "GP_E_UNKNOWN";
152
156
  var GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
153
157
  function formatToolError(code, message, remedy) {
@@ -229,6 +233,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
229
233
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
230
234
  }
231
235
  var ENGINE_PROTOCOL_VERSION = "0";
236
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
232
237
  var EngineCallError = class extends Error {
233
238
  code;
234
239
  message;
@@ -272,6 +277,9 @@ var NARROWING_AUTHORITY = {
272
277
  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",
273
278
  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"
274
279
  };
280
+ function writeBacklogRemedy(backlogBytes, limitBytes) {
281
+ return `this shell wrote nothing: ${backlogBytes} bytes \u2014 over this gate's ${limitBytes} \u2014 are queued in THIS process for the engine socket, and the service accepted the connection and is not reading it. That is a fact about this shell's buffer, not a diagnosis of why: one long operation and a service wedged solid look identical from here, which is why this answer orders neither a restart nor a probe. Stop sending. Do not retry this call in a loop: every attempt is refused by this same measurement, and a refusal writes no bytes, so a loop moves only the clock, not the queue. What is already queued is the service's to read, so wait for the backlog to drain and send again after that \u2014 this gate is re-measured per call, so the first call accepted after the wait is the proof that it drained, and it is a first send rather than a second copy of an action, because nothing went out the first time. While waiting, read the daemon's own log: it is the only side that can say what it is stuck on, and this shell cannot see past its own buffer. For the service installed by this repo's installer that log is \`~/.glasspane/installer-daemon.log\`, the file its stderr is redirected into, in the same directory as the default socket; a daemon started by hand writes wherever its operator pointed its stderr.`;
282
+ }
275
283
  function clientClosingRemedy(surface) {
276
284
  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
285
  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.`;
@@ -414,6 +422,16 @@ var EngineJsonRpcClient = class {
414
422
  fail: reject
415
423
  };
416
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_BACKLOG,
430
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
431
+ writeBacklogRemedy(backlog, ENGINE_WRITE_BACKLOG_BYTES)
432
+ ));
433
+ return;
434
+ }
417
435
  this.pending.set(id, entry);
418
436
  try {
419
437
  this.io.writeLine(JSON.stringify(frame));
@@ -470,13 +488,18 @@ var EngineJsonRpcClient = class {
470
488
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
471
489
  }
472
490
  handleMessage(line) {
473
- let frame;
491
+ let parsed;
474
492
  try {
475
- frame = JSON.parse(line);
493
+ parsed = JSON.parse(line);
476
494
  } catch {
477
495
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
478
496
  return;
479
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;
480
503
  const id = frame.id;
481
504
  if (typeof id === "number") {
482
505
  const entry = this.pending.get(id);
@@ -521,9 +544,17 @@ var EngineJsonRpcClient = class {
521
544
  settleFromFrame(entry, frame) {
522
545
  if (frame.error) {
523
546
  this.reject(entry, this.engineError(frame.error));
524
- } else {
525
- 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;
526
556
  }
557
+ this.resolve(entry, frame.result);
527
558
  }
528
559
  /**
529
560
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -750,6 +781,9 @@ var ReconnectingSocketIo = class {
750
781
  writeLine(line) {
751
782
  this.connect().writeLine(line);
752
783
  }
784
+ bufferedBytes() {
785
+ return this.inner?.bufferedBytes() ?? 0;
786
+ }
753
787
  onMessage(handler) {
754
788
  this.messageHandler = handler;
755
789
  }
@@ -883,7 +917,7 @@ function renderMarkdown(pack, diagnostics) {
883
917
  out.push(section.body);
884
918
  out.push("");
885
919
  }
886
- return out.join("\n");
920
+ return escapeTerminalControl(out.join("\n"));
887
921
  }
888
922
  function renderHTML(pack, diagnostics) {
889
923
  const out = [];
@@ -908,7 +942,10 @@ function renderHTML(pack, diagnostics) {
908
942
  out.push(`</section>`);
909
943
  }
910
944
  out.push(`</div>`);
911
- return out.join("\n");
945
+ return escapeTerminalControl(out.join("\n"));
946
+ }
947
+ function escapeTerminalControl(value) {
948
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
912
949
  }
913
950
  function escapeHTML(value) {
914
951
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -1235,7 +1272,11 @@ var RECIPE_MAX_STEPS = 64;
1235
1272
  var RecipeStepKindSchema = z3.enum(["act", "observe", "assert", "diagnose"]);
1236
1273
  var RecipeStepSchema = z3.strictObject({
1237
1274
  kind: RecipeStepKindSchema,
1238
- params: z3.record(z3.unknown())
1275
+ // Two-argument `z.record`: valid on the zod 3 this package pins *and* on zod 4,
1276
+ // which made the key type mandatory. The fork of opencode runs zod 4, and its
1277
+ // typecheck caught this as `TS2554: Expected 2-3 arguments, but got 1` — a
1278
+ // latent break waiting for the day this package's zod is bumped.
1279
+ params: z3.record(z3.string(), z3.unknown())
1239
1280
  });
1240
1281
  var RecipeConfigSchema = z3.strictObject({
1241
1282
  schemaVersion: z3.literal(RECIPE_SCHEMA_VERSION),
@@ -1271,8 +1312,14 @@ function parseEvidencePackRead(input) {
1271
1312
  throw schemaError("evidence pack", result.error);
1272
1313
  }
1273
1314
 
1315
+ // ../kernel/dist/decision-log.js
1316
+ var DecisionLogDraftSchema = DecisionLogEntrySchema.omit({
1317
+ sequence: true,
1318
+ prevEntryHash: true
1319
+ });
1320
+
1274
1321
  // src/canonical.ts
1275
- function canonicalJson(value) {
1322
+ function canonicalJson2(value) {
1276
1323
  return JSON.stringify(sortKeys(value));
1277
1324
  }
1278
1325
  function sortKeys(value) {
@@ -1492,6 +1539,27 @@ function tightenMode(target, want, kind) {
1492
1539
  );
1493
1540
  }
1494
1541
  }
1542
+ function fileIdentity(filePath) {
1543
+ try {
1544
+ const stat = fs2.statSync(filePath);
1545
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
1546
+ } catch (error) {
1547
+ if (nodeErrorCode(error) === "ENOENT") return null;
1548
+ throw error;
1549
+ }
1550
+ }
1551
+ function registryDriftReason(filePath, openedAs, now) {
1552
+ if (now === openedAs) {
1553
+ return null;
1554
+ }
1555
+ 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.`;
1556
+ }
1557
+ function assertNoConcurrentWrite(filePath, openedAs) {
1558
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
1559
+ if (reason !== null) {
1560
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
1561
+ }
1562
+ }
1495
1563
  function saveProjects(filePath, entries) {
1496
1564
  const dir = path2.dirname(filePath);
1497
1565
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -1538,6 +1606,7 @@ function projectSet(args) {
1538
1606
  assertStorablePid(args.pid);
1539
1607
  const snapshot = loadRegistry(filePath);
1540
1608
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
1609
+ const openedAs = fileIdentity(filePath);
1541
1610
  let entry;
1542
1611
  const projectId = args.projectId;
1543
1612
  if (projectId) {
@@ -1563,6 +1632,7 @@ function projectSet(args) {
1563
1632
  assertSingleIdentity(entry);
1564
1633
  projects.push(entry);
1565
1634
  }
1635
+ assertNoConcurrentWrite(filePath, openedAs);
1566
1636
  saveProjects(filePath, projects);
1567
1637
  return {
1568
1638
  ...entry,
@@ -2354,7 +2424,7 @@ async function captureView(args, context) {
2354
2424
  };
2355
2425
  }
2356
2426
  const mimeType = typeof body.mimeType === "string" && body.mimeType.length > 0 ? body.mimeType : "image/png";
2357
- const summary = canonicalJson({
2427
+ const summary = canonicalJson2({
2358
2428
  mimeType,
2359
2429
  byteCount: body.byteCount,
2360
2430
  pixelWidth: body.pixelWidth,
@@ -2418,7 +2488,7 @@ async function projectListTool() {
2418
2488
  try {
2419
2489
  const projects = projectList();
2420
2490
  return {
2421
- content: [{ type: "text", text: canonicalJson({ projects }) }],
2491
+ content: [{ type: "text", text: canonicalJson2({ projects }) }],
2422
2492
  isError: false
2423
2493
  };
2424
2494
  } catch (error) {
@@ -2428,7 +2498,7 @@ async function projectListTool() {
2428
2498
  async function projectSetTool(args) {
2429
2499
  try {
2430
2500
  const entry = projectSet(args);
2431
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2501
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2432
2502
  } catch (error) {
2433
2503
  return mapProjectError(error);
2434
2504
  }
@@ -2450,7 +2520,7 @@ async function projectGetTool(args) {
2450
2520
  isError: true
2451
2521
  };
2452
2522
  }
2453
- return { content: [{ type: "text", text: canonicalJson({ project: entry }) }], isError: false };
2523
+ return { content: [{ type: "text", text: canonicalJson2({ project: entry }) }], isError: false };
2454
2524
  } catch (error) {
2455
2525
  return mapProjectError(error);
2456
2526
  }
@@ -3314,6 +3384,8 @@ function httpStatusForEngineCode(code) {
3314
3384
  return 409;
3315
3385
  case GP_E_ENGINE_UNREACHABLE:
3316
3386
  return 503;
3387
+ case GP_E_ENGINE_BACKLOG:
3388
+ return 503;
3317
3389
  case GP_E_ENGINE_TIMEOUT:
3318
3390
  return 504;
3319
3391
  case GP_E_PAYLOAD_TOO_LARGE:
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
  }
@@ -789,6 +807,7 @@ var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
789
807
  var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
790
808
  var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
791
809
  var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
810
+ var GP_E_ENGINE_BACKLOG = "GP_E_ENGINE_BACKLOG";
792
811
  var GP_E_UNKNOWN = "GP_E_UNKNOWN";
793
812
  var GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
794
813
  function formatToolError(code, message, remedy) {
@@ -874,6 +893,7 @@ function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS)
874
893
  return Math.min(engineDeadlineMs(method, params), ceilingMs);
875
894
  }
876
895
  var ENGINE_PROTOCOL_VERSION = "0";
896
+ var ENGINE_WRITE_BACKLOG_BYTES = 8 * 1024 * 1024;
877
897
  var EngineCallError = class extends Error {
878
898
  code;
879
899
  message;
@@ -917,6 +937,9 @@ var NARROWING_AUTHORITY = {
917
937
  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",
918
938
  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"
919
939
  };
940
+ function writeBacklogRemedy(backlogBytes, limitBytes) {
941
+ return `this shell wrote nothing: ${backlogBytes} bytes \u2014 over this gate's ${limitBytes} \u2014 are queued in THIS process for the engine socket, and the service accepted the connection and is not reading it. That is a fact about this shell's buffer, not a diagnosis of why: one long operation and a service wedged solid look identical from here, which is why this answer orders neither a restart nor a probe. Stop sending. Do not retry this call in a loop: every attempt is refused by this same measurement, and a refusal writes no bytes, so a loop moves only the clock, not the queue. What is already queued is the service's to read, so wait for the backlog to drain and send again after that \u2014 this gate is re-measured per call, so the first call accepted after the wait is the proof that it drained, and it is a first send rather than a second copy of an action, because nothing went out the first time. While waiting, read the daemon's own log: it is the only side that can say what it is stuck on, and this shell cannot see past its own buffer. For the service installed by this repo's installer that log is \`~/.glasspane/installer-daemon.log\`, the file its stderr is redirected into, in the same directory as the default socket; a daemon started by hand writes wherever its operator pointed its stderr.`;
942
+ }
920
943
  function clientClosingRemedy(surface) {
921
944
  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";
922
945
  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.`;
@@ -1059,6 +1082,16 @@ var EngineJsonRpcClient = class {
1059
1082
  fail: reject
1060
1083
  };
1061
1084
  entry.timer = setTimeout(() => this.onDeadline(entry), deadlineMs);
1085
+ const backlog = this.io.bufferedBytes?.() ?? 0;
1086
+ if (backlog > ENGINE_WRITE_BACKLOG_BYTES) {
1087
+ this.pending.delete(id);
1088
+ this.reject(entry, new EngineCallError(
1089
+ GP_E_ENGINE_BACKLOG,
1090
+ `not sending '${method}': ${backlog} bytes are already queued for the engine socket and it has not read them`,
1091
+ writeBacklogRemedy(backlog, ENGINE_WRITE_BACKLOG_BYTES)
1092
+ ));
1093
+ return;
1094
+ }
1062
1095
  this.pending.set(id, entry);
1063
1096
  try {
1064
1097
  this.io.writeLine(JSON.stringify(frame));
@@ -1115,13 +1148,18 @@ var EngineJsonRpcClient = class {
1115
1148
  this.report(`dropped late-reply tracking for '${evicted.method}' (id ${evicted.id}): retention cap ${LATE_REPLY_RETENTION} reached`);
1116
1149
  }
1117
1150
  handleMessage(line) {
1118
- let frame;
1151
+ let parsed;
1119
1152
  try {
1120
- frame = JSON.parse(line);
1153
+ parsed = JSON.parse(line);
1121
1154
  } catch {
1122
1155
  this.report(`engine sent a non-JSON frame, dropped: ${truncate(line)}`);
1123
1156
  return;
1124
1157
  }
1158
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1159
+ this.report(`engine sent a frame that is not a JSON object, dropped: ${truncate(line)}`);
1160
+ return;
1161
+ }
1162
+ const frame = parsed;
1125
1163
  const id = frame.id;
1126
1164
  if (typeof id === "number") {
1127
1165
  const entry = this.pending.get(id);
@@ -1166,9 +1204,17 @@ var EngineJsonRpcClient = class {
1166
1204
  settleFromFrame(entry, frame) {
1167
1205
  if (frame.error) {
1168
1206
  this.reject(entry, this.engineError(frame.error));
1169
- } else {
1170
- this.resolve(entry, frame.result);
1207
+ return;
1208
+ }
1209
+ if (!("result" in frame)) {
1210
+ this.reject(entry, new EngineCallError(
1211
+ GP_E_INTERNAL,
1212
+ `engine answered '${entry.method}' with a frame carrying neither result nor error`,
1213
+ "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"
1214
+ ));
1215
+ return;
1171
1216
  }
1217
+ this.resolve(entry, frame.result);
1172
1218
  }
1173
1219
  /**
1174
1220
  * A frame over the cap: attribute it to the request it belonged to — from
@@ -1395,6 +1441,9 @@ var ReconnectingSocketIo = class {
1395
1441
  writeLine(line) {
1396
1442
  this.connect().writeLine(line);
1397
1443
  }
1444
+ bufferedBytes() {
1445
+ return this.inner?.bufferedBytes() ?? 0;
1446
+ }
1398
1447
  onMessage(handler) {
1399
1448
  this.messageHandler = handler;
1400
1449
  }
@@ -1765,7 +1814,7 @@ function renderMarkdown(pack, diagnostics) {
1765
1814
  out.push(section.body);
1766
1815
  out.push("");
1767
1816
  }
1768
- return out.join("\n");
1817
+ return escapeTerminalControl(out.join("\n"));
1769
1818
  }
1770
1819
  function renderHTML(pack, diagnostics) {
1771
1820
  const out = [];
@@ -1790,7 +1839,10 @@ function renderHTML(pack, diagnostics) {
1790
1839
  out.push(`</section>`);
1791
1840
  }
1792
1841
  out.push(`</div>`);
1793
- return out.join("\n");
1842
+ return escapeTerminalControl(out.join("\n"));
1843
+ }
1844
+ function escapeTerminalControl(value) {
1845
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
1794
1846
  }
1795
1847
  function escapeHTML(value) {
1796
1848
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -2106,6 +2158,27 @@ function tightenMode(target, want, kind) {
2106
2158
  );
2107
2159
  }
2108
2160
  }
2161
+ function fileIdentity(filePath) {
2162
+ try {
2163
+ const stat = fs3.statSync(filePath);
2164
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
2165
+ } catch (error) {
2166
+ if (nodeErrorCode(error) === "ENOENT") return null;
2167
+ throw error;
2168
+ }
2169
+ }
2170
+ function registryDriftReason(filePath, openedAs, now) {
2171
+ if (now === openedAs) {
2172
+ return null;
2173
+ }
2174
+ 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.`;
2175
+ }
2176
+ function assertNoConcurrentWrite(filePath, openedAs) {
2177
+ const reason = registryDriftReason(filePath, openedAs, fileIdentity(filePath));
2178
+ if (reason !== null) {
2179
+ throw new ProjectRegistryError("registry-changed-underneath", reason);
2180
+ }
2181
+ }
2109
2182
  function saveProjects(filePath, entries) {
2110
2183
  const dir = path3.dirname(filePath);
2111
2184
  const tmpPath = `${filePath}.tmp-${uniqueToken()}`;
@@ -2152,6 +2225,7 @@ function projectSet(args) {
2152
2225
  assertStorablePid(args.pid);
2153
2226
  const snapshot = loadRegistry(filePath);
2154
2227
  const { projects, discarded } = requireUsableRegistry(filePath, snapshot);
2228
+ const openedAs = fileIdentity(filePath);
2155
2229
  let entry;
2156
2230
  const projectId = args.projectId;
2157
2231
  if (projectId) {
@@ -2177,6 +2251,7 @@ function projectSet(args) {
2177
2251
  assertSingleIdentity(entry);
2178
2252
  projects.push(entry);
2179
2253
  }
2254
+ assertNoConcurrentWrite(filePath, openedAs);
2180
2255
  saveProjects(filePath, projects);
2181
2256
  return {
2182
2257
  ...entry,
@@ -3590,7 +3665,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3590
3665
  "2025-03-26",
3591
3666
  MCP_PROTOCOL_VERSION
3592
3667
  ];
3593
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.6.2" };
3668
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.8.1" };
3594
3669
  var PARSE_ERROR = -32700;
3595
3670
  var INVALID_REQUEST = -32600;
3596
3671
  var METHOD_NOT_FOUND = -32601;
@@ -3793,6 +3868,12 @@ function parseArgs(argv) {
3793
3868
  i += 1;
3794
3869
  } else if (arg.startsWith("--socket-path=")) {
3795
3870
  options.socketPath = arg.slice("--socket-path=".length);
3871
+ } else {
3872
+ throw new Error(
3873
+ `unknown argument: ${arg}
3874
+ ${USAGE}
3875
+ 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.`
3876
+ );
3796
3877
  }
3797
3878
  }
3798
3879
  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.8.1",
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",