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.
- package/dist/dispatch.d.ts +1 -1
- package/dist/engine-client.d.ts +10 -0
- package/dist/errors.d.ts +14 -0
- package/dist/evidence-report.d.ts +13 -0
- package/dist/http-gateway-cli.js +84 -12
- package/dist/http-gateway.js +84 -12
- package/dist/index.js +92 -11
- package/dist/io.d.ts +11 -0
- package/dist/project-registry.d.ts +7 -0
- package/package.json +1 -1
package/dist/dispatch.d.ts
CHANGED
|
@@ -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.
|
|
29
|
+
readonly version: "1.8.1";
|
|
30
30
|
};
|
|
31
31
|
export declare const PARSE_ERROR = -32700;
|
|
32
32
|
export declare const INVALID_REQUEST = -32600;
|
package/dist/engine-client.d.ts
CHANGED
|
@@ -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
|
package/dist/http-gateway-cli.js
CHANGED
|
@@ -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
|
|
495
|
+
let parsed;
|
|
478
496
|
try {
|
|
479
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
package/dist/http-gateway.js
CHANGED
|
@@ -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
|
|
491
|
+
let parsed;
|
|
474
492
|
try {
|
|
475
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
1151
|
+
let parsed;
|
|
1119
1152
|
try {
|
|
1120
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -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.
|
|
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