glasspane-mcp 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit-session.d.ts +82 -5
- package/dist/dispatch.d.ts +38 -3
- package/dist/engine-client.d.ts +141 -27
- package/dist/errors.d.ts +36 -6
- package/dist/http-gateway-cli.js +211 -32
- package/dist/http-gateway.d.ts +21 -0
- package/dist/http-gateway.js +211 -32
- package/dist/index.js +386 -46
- package/dist/project-registry.d.ts +6 -0
- package/dist/tools.d.ts +44 -0
- package/package.json +1 -1
package/dist/http-gateway-cli.js
CHANGED
|
@@ -148,6 +148,8 @@ var GP_E_PROJECT_LIMIT = "GP_E_PROJECT_LIMIT";
|
|
|
148
148
|
var GP_E_NOT_FOUND = "GP_E_NOT_FOUND";
|
|
149
149
|
var GP_E_ENGINE_TIMEOUT = "GP_E_ENGINE_TIMEOUT";
|
|
150
150
|
var GP_E_PAYLOAD_TOO_LARGE = "GP_E_PAYLOAD_TOO_LARGE";
|
|
151
|
+
var GP_E_UNKNOWN = "GP_E_UNKNOWN";
|
|
152
|
+
var GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
|
|
151
153
|
function formatToolError(code, message, remedy) {
|
|
152
154
|
return `${code}: ${message} | remedy: ${remedy}`;
|
|
153
155
|
}
|
|
@@ -169,8 +171,9 @@ function defaultSocketPath() {
|
|
|
169
171
|
return process.env[ENGINE_SOCKET_ENV] ?? path.join(os.homedir(), ".glasspane", "engine.sock");
|
|
170
172
|
}
|
|
171
173
|
var ENGINE_TIMEOUT_MS = 1e4;
|
|
174
|
+
var CALLER_VISIBLE_CEILING_MS = 5e4;
|
|
172
175
|
var ENGINE_DEADLINES_MS = {
|
|
173
|
-
hello
|
|
176
|
+
// `hello` is at the bottom of this table, where its reason is written out.
|
|
174
177
|
attach: 3e4,
|
|
175
178
|
act: 12e4,
|
|
176
179
|
observe: 45e3,
|
|
@@ -178,14 +181,35 @@ var ENGINE_DEADLINES_MS = {
|
|
|
178
181
|
diagnose: 3e4,
|
|
179
182
|
last_evidence: 6e4,
|
|
180
183
|
snapshot: 9e4,
|
|
181
|
-
probe_status:
|
|
182
|
-
|
|
184
|
+
probe_status: CALLER_VISIBLE_CEILING_MS,
|
|
185
|
+
// R8b-高4: the same trap as `probe_status`, one layer down. `handshake()` is
|
|
186
|
+
// fired by the transport's `connect` event, but `ReconnectingSocketIo` is lazy:
|
|
187
|
+
// the frame that *causes* the connection is written first, so when a session's
|
|
188
|
+
// very first request is an `act`, `hello` is queued behind it. At 15 s the
|
|
189
|
+
// handshake expired against a healthy daemon every time, R4-06's protocol and
|
|
190
|
+
// version comparison never ran, and the only trace was one stderr line — a
|
|
191
|
+
// silent loss of the one measurement that catches "an old daemon is under this
|
|
192
|
+
// shell". It gets the full client wait so the comparison happens.
|
|
193
|
+
hello: CALLER_VISIBLE_CEILING_MS,
|
|
194
|
+
shutdown: 15e3,
|
|
195
|
+
// R8-中5: these two used to ride `ENGINE_TIMEOUT_MS * 3` by omission, which is
|
|
196
|
+
// a deadline nobody derived from the daemon. Both are on the wire
|
|
197
|
+
// (`gp_audit_ui` forwards `audit_ui`; `gp_capture_view` calls `capture_view`
|
|
198
|
+
// from its own executor), and `test/method-table.test.mjs` now refuses any
|
|
199
|
+
// sent method that is missing here.
|
|
200
|
+
//
|
|
201
|
+
// `audit_ui` is one geometry snapshot walk (`AXChannel.geometrySnapshot`,
|
|
202
|
+
// capped by `AXChannel.treeTimeoutSeconds` = 10 s) plus a pure-geometry
|
|
203
|
+
// overlap scan. It rides `observe`'s number rather than a fresh guess: same
|
|
204
|
+
// tree walk, without observe's pixel round trip.
|
|
205
|
+
audit_ui: 45e3,
|
|
206
|
+
// `capture_view` is attach + `SCKCapturer.captureWindow` + PNG encode. The
|
|
207
|
+
// capture tries up to two candidate surfaces at `captureTimeoutSeconds` = 5 s
|
|
208
|
+
// each (R6-11 made the second candidate conditional, so two attempts is the
|
|
209
|
+
// real worst case), then encodes inside the frame budget.
|
|
210
|
+
capture_view: 3e4
|
|
183
211
|
};
|
|
184
212
|
var RESTORE_BASE_DEADLINE_MS = 3e4;
|
|
185
|
-
var CALLER_VISIBLE_CEILING_MS = 5e4;
|
|
186
|
-
function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS) {
|
|
187
|
-
return Math.min(engineDeadlineMs(method, params), ceilingMs);
|
|
188
|
-
}
|
|
189
213
|
var REPLAY_UNSAFE_METHODS = /* @__PURE__ */ new Set(["act", "restore"]);
|
|
190
214
|
function engineDeadlineMs(method, params) {
|
|
191
215
|
if (method === "restore") {
|
|
@@ -196,6 +220,9 @@ function engineDeadlineMs(method, params) {
|
|
|
196
220
|
const known = ENGINE_DEADLINES_MS[method];
|
|
197
221
|
return known ?? ENGINE_TIMEOUT_MS * 3;
|
|
198
222
|
}
|
|
223
|
+
function callerDeadlineMs(method, params, ceilingMs = CALLER_VISIBLE_CEILING_MS) {
|
|
224
|
+
return Math.min(engineDeadlineMs(method, params), ceilingMs);
|
|
225
|
+
}
|
|
199
226
|
var ENGINE_PROTOCOL_VERSION = "0";
|
|
200
227
|
var EngineCallError = class extends Error {
|
|
201
228
|
code;
|
|
@@ -212,21 +239,50 @@ var EngineCallError = class extends Error {
|
|
|
212
239
|
return { code: this.code, message: this.message, remedy: this.remedy };
|
|
213
240
|
}
|
|
214
241
|
};
|
|
215
|
-
|
|
216
|
-
|
|
242
|
+
var LIVENESS_PROBE_OUTCOMES = [
|
|
243
|
+
"answered",
|
|
244
|
+
"engine-error",
|
|
245
|
+
"timed-out",
|
|
246
|
+
"unreachable"
|
|
247
|
+
];
|
|
248
|
+
function livenessProbeDecision(outcome) {
|
|
249
|
+
switch (outcome) {
|
|
250
|
+
case "answered":
|
|
251
|
+
return "answered at all (a result or an engine error frame) => the daemon is alive and has worked its way past the request in front of yours: keep waiting, the outstanding call's reply is still attributed and recorded";
|
|
252
|
+
case "engine-error":
|
|
253
|
+
return "answered with a named engine error code => the daemon is alive, so this answer never means \"restart the daemon because it is wedged\"; follow that code's own remedy instead, and note that some codes genuinely do name a restart (`GP_E_AX_UNAVAILABLE` does, because a wedged Accessibility API needs the service recycled) \u2014 that is the daemon's decision to make, not this sentence's";
|
|
254
|
+
case "timed-out":
|
|
255
|
+
return `not answered within its own window => NOT evidence that the daemon is down: it is single-connection and still busy with the earlier request (this is ${GP_E_ENGINE_TIMEOUT}, and the request stays registered so the real reply is attributed when it lands). Keep waiting; do not run the restore command on this answer alone, because restarting cancels and rolls back an action that is mid-flight on the user's screen`;
|
|
256
|
+
case "unreachable":
|
|
257
|
+
return `${GP_E_ENGINE_UNREACHABLE} (nothing is listening, or the transport died) => this is the one answer that justifies touching the daemon's lifecycle: ${daemonUnreachableRemedy()}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function livenessProbeGuide() {
|
|
261
|
+
return LIVENESS_PROBE_OUTCOMES.map((outcome) => `${outcome} \u2014 ${livenessProbeDecision(outcome)}`).join("; ");
|
|
262
|
+
}
|
|
263
|
+
function lateReplyRoute(surface) {
|
|
264
|
+
return surface === "mcp" ? "the original reply, when it lands, is written to this server's log (stderr) *and* its operationId is recorded in this session's trail, so a later gp_recent_reports lists the operation that ran instead of telling you to run it again \u2014 **while this server still tracks the request**: the window is bounded (" + LATE_REPLY_RETENTION + " timed-out requests, and an eviction is logged), a re-attach in between supersedes it (also logged, with the operationIds it dropped), and if this process has exited nothing will be recorded at all" : "the original reply, when it lands, is written to this gateway's stderr with its body, so the operationId it carries can be read off that line and fetched with GET /v1/evidence/<operationId> \u2014 the tracking window is bounded (" + LATE_REPLY_RETENTION + " timed-out requests, and an eviction is logged), and this gateway keeps no operation trail of its own";
|
|
265
|
+
}
|
|
266
|
+
function slowEngineRemedy(method, surface = "mcp") {
|
|
267
|
+
const poll = "if your client can send a second request while this one is still outstanding, confirm the daemon is alive with a cheap call: the shell does not queue one MCP request behind another, so gp_probe_status goes out at once and is answered as soon as the daemon is free of the request in front of it. Read its answer as: " + livenessProbeGuide();
|
|
268
|
+
const restarted = livenessProbeDecision("unreachable");
|
|
217
269
|
if (REPLAY_UNSAFE_METHODS.has(method)) {
|
|
218
|
-
return `${poll}. The daemon serves one request at a time and is still working on this one, so wait for it; do NOT re-issue ${method}, because the original action can still take effect on the user's screen \u2014
|
|
270
|
+
return `${poll}. The daemon serves one request at a time and is still working on this one, so wait for it; do NOT re-issue ${method}, because the original action can still take effect on the user's screen \u2014 ${lateReplyRoute(surface)}. Only ${restarted}`;
|
|
219
271
|
}
|
|
220
|
-
|
|
272
|
+
if (method === "probe_status") {
|
|
273
|
+
return `the liveness probe itself has not been answered, and it is given the longest wait this shell allows any request (${CALLER_VISIBLE_CEILING_MS}ms), so this particular answer carries no information about whether the daemon is alive: the daemon is single-connection and is still working on the request in front of this probe. ${livenessProbeDecision("timed-out")}. Keep waiting instead \u2014 the outstanding call's reply is attributed when it lands, and ${lateReplyRoute(surface)}. If the daemon's socket really is gone, the next call answers with ${GP_E_ENGINE_UNREACHABLE}, and that remedy names the restore command.`;
|
|
274
|
+
}
|
|
275
|
+
return `${poll}. The daemon serves one request at a time and is still working on this one, so wait, then retry this read narrower (smaller maxDepth / a tighter selector) \u2014 that is safe for a read; a restart is authorised only by ${restarted}`;
|
|
221
276
|
}
|
|
222
277
|
var LATE_REPLY_RETENTION = 16;
|
|
223
278
|
var LOG_BODY_CHARS = 64 * 1024;
|
|
224
279
|
var EngineJsonRpcClient = class {
|
|
225
|
-
constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS) {
|
|
280
|
+
constructor(io, timeoutMs, identity = null, callerCeilingMs = CALLER_VISIBLE_CEILING_MS, surface = "mcp") {
|
|
226
281
|
this.io = io;
|
|
227
282
|
this.timeoutMs = timeoutMs;
|
|
228
283
|
this.identity = identity;
|
|
229
284
|
this.callerCeilingMs = callerCeilingMs;
|
|
285
|
+
this.surface = surface;
|
|
230
286
|
this.io.onMessage((line) => this.handleMessage(line));
|
|
231
287
|
this.io.onError((error) => {
|
|
232
288
|
if (error instanceof OversizeFrameError) {
|
|
@@ -250,10 +306,12 @@ var EngineJsonRpcClient = class {
|
|
|
250
306
|
timeoutMs;
|
|
251
307
|
identity;
|
|
252
308
|
callerCeilingMs;
|
|
309
|
+
surface;
|
|
253
310
|
nextId = 0;
|
|
254
311
|
closed = false;
|
|
255
312
|
pending = /* @__PURE__ */ new Map();
|
|
256
313
|
noteHandler = null;
|
|
314
|
+
lateReplyHandler = null;
|
|
257
315
|
/**
|
|
258
316
|
* Sink for facts that cannot be attached to any request of the caller: a
|
|
259
317
|
* late reply, a frame with an id this client never issued, a daemon that
|
|
@@ -263,8 +321,26 @@ var EngineJsonRpcClient = class {
|
|
|
263
321
|
onEngineNote(handler) {
|
|
264
322
|
this.noteHandler = handler;
|
|
265
323
|
}
|
|
266
|
-
/**
|
|
267
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Sink for the *body* of a reply that lands after its caller was already
|
|
326
|
+
* answered (R8-高3). Logging it is not enough: the operationId in that frame
|
|
327
|
+
* is the only thing that puts the operation into this session's evidence
|
|
328
|
+
* trail, and without it `gp_recent_reports` answers
|
|
329
|
+
* `GP_E_NO_EVIDENCE ... run gp_act first` for an act that genuinely ran and
|
|
330
|
+
* genuinely mutated the user's screen — an agent reading that answer clicks
|
|
331
|
+
* again. The entry the reply is attributed to is only kept for
|
|
332
|
+
* `LATE_REPLY_RETENTION` requests, so this sink sees the tracked window and
|
|
333
|
+
* the eviction note says when something fell outside it.
|
|
334
|
+
*/
|
|
335
|
+
onLateReply(handler) {
|
|
336
|
+
this.lateReplyHandler = handler;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Send a request and await its matching response frame. `correlation` is
|
|
340
|
+
* echoed to {@link onLateReply} if this request is answered after its caller
|
|
341
|
+
* has already been given a timeout; it is otherwise unused by this client.
|
|
342
|
+
*/
|
|
343
|
+
call(method, params, correlation) {
|
|
268
344
|
const id = this.nextId++;
|
|
269
345
|
const frame = { id, method, ...params === void 0 ? {} : { params } };
|
|
270
346
|
const methodDeadlineMs = this.timeoutMs ?? engineDeadlineMs(method, params);
|
|
@@ -273,6 +349,8 @@ var EngineJsonRpcClient = class {
|
|
|
273
349
|
const entry = {
|
|
274
350
|
id,
|
|
275
351
|
method,
|
|
352
|
+
correlation,
|
|
353
|
+
params,
|
|
276
354
|
deadlineMs,
|
|
277
355
|
methodDeadlineMs,
|
|
278
356
|
timer: null,
|
|
@@ -316,7 +394,7 @@ var EngineJsonRpcClient = class {
|
|
|
316
394
|
this.reject(entry, new EngineCallError(
|
|
317
395
|
GP_E_ENGINE_TIMEOUT,
|
|
318
396
|
`engine is still working on '${entry.method}': no reply after ${entry.deadlineMs}ms. The request stays registered \u2014 a late reply is written to the server log \u2014 so this is a busy or slow daemon, not an unreachable one.` + capped,
|
|
319
|
-
slowEngineRemedy(entry.method)
|
|
397
|
+
slowEngineRemedy(entry.method, this.surface)
|
|
320
398
|
));
|
|
321
399
|
this.retainForLateReply();
|
|
322
400
|
}
|
|
@@ -403,12 +481,12 @@ var EngineJsonRpcClient = class {
|
|
|
403
481
|
this.reject(entry, attribute(new EngineCallError(
|
|
404
482
|
GP_E_PAYLOAD_TOO_LARGE,
|
|
405
483
|
`the engine's reply to '${entry.method}' exceeded the ${MAX_FRAME_BYTES}-byte frame cap (${error.bytes} bytes) and was dropped; the connection is intact and other requests are unaffected`,
|
|
406
|
-
"narrow this request
|
|
484
|
+
"narrow this request or ask for less of it; the tool's own schema names the parameters that can shrink a reply, and this shell rewrites this line into that advice when it can (the transport layer deliberately does not guess them)"
|
|
407
485
|
), entry, inferred));
|
|
408
486
|
}
|
|
409
487
|
engineError(body) {
|
|
410
488
|
return new EngineCallError(
|
|
411
|
-
String(body.code ??
|
|
489
|
+
String(body.code ?? GP_E_UNKNOWN),
|
|
412
490
|
String(body.message ?? "the engine sent an error frame without a message"),
|
|
413
491
|
typeof body.remedy === "string" && body.remedy !== "" ? body.remedy : "the engine sent no remedy for this error code; check the daemon log (stderr) and retry"
|
|
414
492
|
);
|
|
@@ -427,6 +505,18 @@ var EngineJsonRpcClient = class {
|
|
|
427
505
|
this.report(
|
|
428
506
|
`late engine reply for '${entry.method}' (id ${entry.id}), ${entry.deadlineMs}ms deadline already reported: ${body}`
|
|
429
507
|
);
|
|
508
|
+
if (frame.error || frame.result === void 0) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const handler = this.lateReplyHandler;
|
|
512
|
+
if (handler === null) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
handler({ method: entry.method, result: frame.result, correlation: entry.correlation });
|
|
517
|
+
} catch (error) {
|
|
518
|
+
this.report(`the late-reply sink failed for '${entry.method}' (id ${entry.id}): ${String(error)}`);
|
|
519
|
+
}
|
|
430
520
|
}
|
|
431
521
|
resolve(entry, value) {
|
|
432
522
|
this.complete(entry);
|
|
@@ -592,7 +682,8 @@ function unixSocketEngineClient(socketPath, options = {}) {
|
|
|
592
682
|
new ReconnectingSocketIo(socketPath, open),
|
|
593
683
|
void 0,
|
|
594
684
|
options.identity ?? null,
|
|
595
|
-
options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS
|
|
685
|
+
options.callerCeilingMs ?? CALLER_VISIBLE_CEILING_MS,
|
|
686
|
+
options.surface ?? "mcp"
|
|
596
687
|
);
|
|
597
688
|
}
|
|
598
689
|
|
|
@@ -1120,8 +1211,21 @@ var PROTECTED_SUBTREES = [
|
|
|
1120
1211
|
"/private/tmp",
|
|
1121
1212
|
"/private/var/tmp"
|
|
1122
1213
|
];
|
|
1214
|
+
function systemHome() {
|
|
1215
|
+
try {
|
|
1216
|
+
return os2.userInfo().homedir;
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
throw new ProjectRegistryError(
|
|
1219
|
+
// Not GP_E_INTERNAL: `tools.ts` maps that code to "the projects file is
|
|
1220
|
+
// damaged, go read it", and for this failure there is no damaged file —
|
|
1221
|
+
// the process has no user record to derive a path from (R8b).
|
|
1222
|
+
GP_E_NO_USER_RECORD,
|
|
1223
|
+
`cannot read the user record this shell's home directory comes from (${describeError(error)}). The background service derives its state root the same way (NSHomeDirectory(), not $HOME), so there is no value here to fall back to: guessing from $HOME would name a projects.json the daemon does not load and would let the home-directory protection guard the wrong tree. Run this shell as a user that has an entry in the password database.`
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1123
1227
|
function daemonProjectsPath() {
|
|
1124
|
-
return path2.join(
|
|
1228
|
+
return path2.join(systemHome(), ".glasspane", "projects.json");
|
|
1125
1229
|
}
|
|
1126
1230
|
var FIRMLINK_PREFIX = "/System/Volumes/Data";
|
|
1127
1231
|
var FORCE_OVERWRITE_ENV = "GLASSPANE_PROJECTS_FORCE_OVERWRITE";
|
|
@@ -1415,7 +1519,7 @@ function badPath(field, value, problem) {
|
|
|
1415
1519
|
}
|
|
1416
1520
|
var PATH_SHAPE_HINT = `An acceptable value is a project-owned directory at least two levels deep, e.g. "/Users/you/work/notes-app/.glasspane/evidence": absolute, with no "." or ".." components, not world-writable, with its nearest existing directory owned by the user running this shell (never a shared tree such as /Users/Shared, another user's home, /private/tmp, or a top-level directory of the boot volume), and not the filesystem root, a mounted volume root, your home directory, anything under ~/Library, or a system-owned tree (${PROTECTED_SUBTREES.join(", ")}) \u2014 the daemon writes evidence into this directory and deletes expired entries from it`;
|
|
1417
1521
|
function refuseCandidate(target) {
|
|
1418
|
-
const home = stripFirmlink(path2.posix.normalize(
|
|
1522
|
+
const home = stripFirmlink(path2.posix.normalize(systemHome()));
|
|
1419
1523
|
if (target === home) return named("the current user's home directory");
|
|
1420
1524
|
if (isInside(`${home}/Library`, target)) return named("inside the current user's Library folder");
|
|
1421
1525
|
if (isProtectedRoot(target)) return named("a protected system root");
|
|
@@ -1547,8 +1651,16 @@ var AttachArgs = z5.strictObject({
|
|
|
1547
1651
|
bundleId: OptionalBundleIdSchema,
|
|
1548
1652
|
pid: OptionalPidSchema,
|
|
1549
1653
|
projectId: OptionalProjectIdSchema
|
|
1550
|
-
}).refine((v) => v.bundleId
|
|
1551
|
-
|
|
1654
|
+
}).refine((v) => v.bundleId === void 0 !== (v.pid === void 0), {
|
|
1655
|
+
// Exclusive, not merely "at least one" (R8-中6). The advertised JSON Schema
|
|
1656
|
+
// below already says `oneOf` (exactly one), and the daemon resolves both
|
|
1657
|
+
// fields by *preferring pid and ignoring bundleId*
|
|
1658
|
+
// (`AXChannel.resolveRunningApplication`: `if let pid { … } if let bundleId { … }`).
|
|
1659
|
+
// So the loose zod rule let an agent name two different apps and get the one
|
|
1660
|
+
// it did not mean, attached, and acting on the user's screen — with the reply
|
|
1661
|
+
// naming only one of them. Refusing is the only answer that cannot mislead.
|
|
1662
|
+
message: "attach takes exactly one of bundleId or pid, not both and not neither",
|
|
1663
|
+
path: ["bundleId"]
|
|
1552
1664
|
});
|
|
1553
1665
|
var ObserveArgs = z5.strictObject({
|
|
1554
1666
|
maxDepth: OptionalDepthSchema,
|
|
@@ -1896,6 +2008,47 @@ var TOOL_SPECS = [
|
|
|
1896
2008
|
var TOOL_BY_NAME = new Map(
|
|
1897
2009
|
TOOL_SPECS.map((spec) => [spec.name, spec])
|
|
1898
2010
|
);
|
|
2011
|
+
function narrowingKnobsFor(spec, params) {
|
|
2012
|
+
const properties = spec.inputSchema.properties ?? {};
|
|
2013
|
+
const knobs = [];
|
|
2014
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
2015
|
+
const schema = raw;
|
|
2016
|
+
const sentValue = params[name];
|
|
2017
|
+
const sent = typeof sentValue === "number" ? sentValue : void 0;
|
|
2018
|
+
if (name === "maxDepth") {
|
|
2019
|
+
knobs.push({ name, kind: "depth", minimum: schema.minimum, maximum: schema.maximum, sent });
|
|
2020
|
+
} else if (name === "scale") {
|
|
2021
|
+
knobs.push({ name, kind: "scale", minimum: schema.minimum, maximum: schema.maximum, sent });
|
|
2022
|
+
} else if (name === "limit") {
|
|
2023
|
+
knobs.push({ name, kind: "limit", minimum: schema.minimum, maximum: schema.maximum, sent });
|
|
2024
|
+
} else if (name === "selector" || name === "role" || name === "title") {
|
|
2025
|
+
knobs.push({ name, kind: "selector", sent: void 0 });
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return knobs;
|
|
2029
|
+
}
|
|
2030
|
+
function describeKnob(knob) {
|
|
2031
|
+
const bounds = knob.minimum !== void 0 && knob.maximum !== void 0 ? ` (this tool advertises ${knob.minimum}\u2026${knob.maximum})` : knob.maximum !== void 0 ? ` (this tool advertises at most ${knob.maximum})` : knob.minimum !== void 0 ? ` (this tool advertises at least ${knob.minimum})` : "";
|
|
2032
|
+
switch (knob.kind) {
|
|
2033
|
+
case "depth":
|
|
2034
|
+
return `a smaller maxDepth${bounds}`;
|
|
2035
|
+
case "limit":
|
|
2036
|
+
return `a smaller limit${bounds}`;
|
|
2037
|
+
case "selector":
|
|
2038
|
+
return knob.name === "selector" ? "a more specific selector (aim at one element)" : `a tighter ${knob.name} filter`;
|
|
2039
|
+
case "scale":
|
|
2040
|
+
return knob.sent !== void 0 && knob.minimum !== void 0 && knob.sent <= knob.minimum ? `scale is already at this tool's floor (${knob.minimum}), so the image cannot be shrunk further here` : `a smaller scale${bounds}${knob.sent !== void 0 ? `, currently ${knob.sent}` : ""}`;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
function oversizedReplyAdvice(spec, params) {
|
|
2044
|
+
const knobs = narrowingKnobsFor(spec, params);
|
|
2045
|
+
const shrinkable = knobs.filter((knob) => !(knob.kind === "scale" && knob.sent !== void 0 && knob.minimum !== void 0 && knob.sent <= knob.minimum));
|
|
2046
|
+
const head = `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); `;
|
|
2047
|
+
if (shrinkable.length === 0) {
|
|
2048
|
+
return knobs.length > 0 ? head + `${knobs.map(describeKnob).join("; ")} \u2014 nothing left to narrow on this request: ask a smaller question (one element, one region) rather than retrying it unchanged; the connection is intact and the daemon keeps answering other requests` : head + "this tool advertises no parameter that shrinks a reply, so do not retry it unchanged \u2014 ask a smaller question (one element, one region) instead; the connection is intact and the daemon keeps answering other requests";
|
|
2049
|
+
}
|
|
2050
|
+
return head + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
|
|
2051
|
+
}
|
|
1899
2052
|
async function readTrail(session, turn, limit) {
|
|
1900
2053
|
await turn.acquire();
|
|
1901
2054
|
try {
|
|
@@ -1936,7 +2089,7 @@ function packForReport(pack, measuredSchemaVersion) {
|
|
|
1936
2089
|
}
|
|
1937
2090
|
async function captureView(args, context) {
|
|
1938
2091
|
try {
|
|
1939
|
-
const raw = await context.engine.call("capture_view", args);
|
|
2092
|
+
const raw = await context.engine.call("capture_view", args, context.session.generation);
|
|
1940
2093
|
const body = raw ?? {};
|
|
1941
2094
|
const png = body.pngBase64;
|
|
1942
2095
|
if (typeof png !== "string" || png.length === 0) {
|
|
@@ -2046,9 +2199,12 @@ async function projectGetTool(args) {
|
|
|
2046
2199
|
if (entry === void 0) {
|
|
2047
2200
|
return {
|
|
2048
2201
|
content: [{ type: "text", text: formatToolError(
|
|
2049
|
-
|
|
2202
|
+
// Both the code and the remedy come from the one place they are
|
|
2203
|
+
// spelled: this used to retype the literal and the sentence, so a
|
|
2204
|
+
// change to `projectErrorRemedy` left this reply behind (R8-低).
|
|
2205
|
+
GP_E_NOT_FOUND,
|
|
2050
2206
|
`unknown project ${argv.projectId}`,
|
|
2051
|
-
|
|
2207
|
+
projectErrorRemedy(GP_E_NOT_FOUND)
|
|
2052
2208
|
) }],
|
|
2053
2209
|
isError: true
|
|
2054
2210
|
};
|
|
@@ -2085,6 +2241,9 @@ function projectErrorRemedy(code) {
|
|
|
2085
2241
|
if (code === GP_E_NOT_FOUND) {
|
|
2086
2242
|
return "check the projectId; use gp_project_list to view available projects";
|
|
2087
2243
|
}
|
|
2244
|
+
if (code === GP_E_NO_USER_RECORD) {
|
|
2245
|
+
return "this process has no entry in the password database, so neither it nor the daemon can know which user's state directory is meant; run the shell as a user that has one (`id -u` / `dscl . -read /Users/<name> NFSHomeDirectory`). Nothing in ~/.glasspane needs repairing for this";
|
|
2246
|
+
}
|
|
2088
2247
|
if (code === GP_E_INTERNAL) {
|
|
2089
2248
|
return `the projects file is the problem, not your arguments: the message above names its path, so read it with \`python3 -m json.tool <that path>\`, repair or restore the damaged entry, and retry \u2014 or set ${FORCE_OVERWRITE_ENV}=1 and call gp_project_set to rebuild the registry from scratch (the unloadable file is moved aside as <path>.unreadable-<id>, never deleted, and every entry still in it is lost). Do not retry the same read-only call unchanged: it will keep failing.`;
|
|
2090
2249
|
}
|
|
@@ -2094,7 +2253,11 @@ async function exportEvidence(args, context) {
|
|
|
2094
2253
|
const argv = args;
|
|
2095
2254
|
const render2 = argv.format === "html" ? renderHTML : renderMarkdown;
|
|
2096
2255
|
try {
|
|
2097
|
-
const raw = await context.engine.call(
|
|
2256
|
+
const raw = await context.engine.call(
|
|
2257
|
+
"last_evidence",
|
|
2258
|
+
{ operationId: argv.operationId },
|
|
2259
|
+
context.session.generation
|
|
2260
|
+
);
|
|
2098
2261
|
const { pack, measuredSchemaVersion } = parseEvidenceFrame(raw);
|
|
2099
2262
|
await context.turn.acquire();
|
|
2100
2263
|
try {
|
|
@@ -2124,7 +2287,11 @@ async function recentReports(args, context) {
|
|
|
2124
2287
|
const skipped = [];
|
|
2125
2288
|
for (const id of ids) {
|
|
2126
2289
|
try {
|
|
2127
|
-
const raw = await context.engine.call(
|
|
2290
|
+
const raw = await context.engine.call(
|
|
2291
|
+
"last_evidence",
|
|
2292
|
+
{ operationId: id },
|
|
2293
|
+
context.session.generation
|
|
2294
|
+
);
|
|
2128
2295
|
const parsed = parseEvidenceFrame(raw);
|
|
2129
2296
|
packs.push({ id, pack: packForReport(parsed.pack, parsed.measuredSchemaVersion) });
|
|
2130
2297
|
} catch (error) {
|
|
@@ -2205,7 +2372,18 @@ function createHttpGateway(options) {
|
|
|
2205
2372
|
`createHttpGateway requires a non-empty token (read ${GLASSPANE_HTTP_TOKEN_ENV}); refusing to start with an empty or defaulted token`
|
|
2206
2373
|
);
|
|
2207
2374
|
}
|
|
2208
|
-
const
|
|
2375
|
+
const report = options.report ?? ((note) => {
|
|
2376
|
+
try {
|
|
2377
|
+
process.stderr.write(`[glasspane-http] ${note}
|
|
2378
|
+
`);
|
|
2379
|
+
} catch {
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
const client = options.connectController !== void 0 ? options.connectController(options.socketPath) : unixSocketEngineClient(options.socketPath, { surface: "http" });
|
|
2383
|
+
client.onEngineNote?.((note) => report(`engine: ${note}`));
|
|
2384
|
+
client.onLateReply?.(({ method, result }) => {
|
|
2385
|
+
report(`late engine reply to '${method}': ${JSON.stringify(result) ?? String(result)}`);
|
|
2386
|
+
});
|
|
2209
2387
|
const expectedToken = Buffer.from(options.token, "utf8");
|
|
2210
2388
|
const server = http.createServer((req, res) => {
|
|
2211
2389
|
handleHttp(req, res, client, expectedToken).catch((error) => {
|
|
@@ -2370,7 +2548,7 @@ async function handleEvidenceAggregate(url, client, res) {
|
|
|
2370
2548
|
if (error instanceof EngineCallError && error.code === GP_E_NO_EVIDENCE) {
|
|
2371
2549
|
skipped.push(operationId);
|
|
2372
2550
|
} else {
|
|
2373
|
-
await failEngine(res, error);
|
|
2551
|
+
await failEngine(res, error, TOOL_BY_NAME.get("gp_last_evidence"), { operationId });
|
|
2374
2552
|
return;
|
|
2375
2553
|
}
|
|
2376
2554
|
}
|
|
@@ -2470,7 +2648,7 @@ async function handleToolForward(name, req, client, res) {
|
|
|
2470
2648
|
}
|
|
2471
2649
|
ok(res, result);
|
|
2472
2650
|
} catch (error) {
|
|
2473
|
-
await failEngine(res, error);
|
|
2651
|
+
await failEngine(res, error, spec, checked.value);
|
|
2474
2652
|
}
|
|
2475
2653
|
}
|
|
2476
2654
|
function handleEvidenceStats(res) {
|
|
@@ -2591,10 +2769,11 @@ function NotFound(res, pathname) {
|
|
|
2591
2769
|
"GET /v1/hello, GET /v1/evidence/:operationId, GET /v1/evidence?ids=a,b&format=html, POST /v1/tools/:name"
|
|
2592
2770
|
);
|
|
2593
2771
|
}
|
|
2594
|
-
async function failEngine(res, error) {
|
|
2772
|
+
async function failEngine(res, error, spec, params) {
|
|
2595
2773
|
if (error instanceof EngineCallError) {
|
|
2596
2774
|
const status = error.code === GP_E_NO_EVIDENCE ? 404 : 502;
|
|
2597
|
-
|
|
2775
|
+
const remedy = spec !== void 0 && error.code === GP_E_PAYLOAD_TOO_LARGE ? oversizedReplyAdvice(spec, params ?? {}) : error.remedy;
|
|
2776
|
+
fail(res, status, error.code, error.message, remedy);
|
|
2598
2777
|
return;
|
|
2599
2778
|
}
|
|
2600
2779
|
fail(
|
package/dist/http-gateway.d.ts
CHANGED
|
@@ -19,6 +19,20 @@ export interface EngineClientLike {
|
|
|
19
19
|
call(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
20
20
|
/** Present on the real client; optional so a fake may omit it. */
|
|
21
21
|
close?(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Sinks for what this gateway cannot answer with: a fact attached to no
|
|
24
|
+
* request (an id-less frame, a handshake that failed) and the body of a reply
|
|
25
|
+
* that landed after its caller was already answered. Both are the daemon's
|
|
26
|
+
* real behaviour, and with no sink they left no trace at all — while the
|
|
27
|
+
* remedy this gateway hands out tells the caller to go read stderr
|
|
28
|
+
* (R8-高3). Optional so a fake may omit it; the gateway refuses to *claim* a
|
|
29
|
+
* log line it did not register for.
|
|
30
|
+
*/
|
|
31
|
+
onEngineNote?(handler: (note: string) => void): void;
|
|
32
|
+
onLateReply?(handler: (reply: {
|
|
33
|
+
method: string;
|
|
34
|
+
result: unknown;
|
|
35
|
+
}) => void): void;
|
|
22
36
|
}
|
|
23
37
|
export interface HttpGatewayOptions {
|
|
24
38
|
/** Daemon socket path handed to the engine client (the default connector). */
|
|
@@ -29,6 +43,13 @@ export interface HttpGatewayOptions {
|
|
|
29
43
|
host?: string;
|
|
30
44
|
/** Injectable client factory so tests run without a real socket. */
|
|
31
45
|
connectController?: (socketPath: string) => EngineClientLike;
|
|
46
|
+
/**
|
|
47
|
+
* Where to write the facts that have no HTTP response: engine notes and late
|
|
48
|
+
* replies. Defaults to stderr with this process's own prefix, because the
|
|
49
|
+
* remedy text this gateway hands out tells a caller to read *this gateway's
|
|
50
|
+
* stderr* — a promise that has to have a writer behind it (R8-高3).
|
|
51
|
+
*/
|
|
52
|
+
report?: (note: string) => void;
|
|
32
53
|
}
|
|
33
54
|
export interface HttpGateway {
|
|
34
55
|
server: Server;
|