@termwright/mcp 0.2.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/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-2J5WHI6X.js +2000 -0
- package/dist/chunk-2J5WHI6X.js.map +1 -0
- package/dist/chunk-36C7A7DW.js +2685 -0
- package/dist/chunk-36C7A7DW.js.map +1 -0
- package/dist/chunk-3PLOAM2C.js +2427 -0
- package/dist/chunk-3PLOAM2C.js.map +1 -0
- package/dist/chunk-57GYK2EF.js +2991 -0
- package/dist/chunk-57GYK2EF.js.map +1 -0
- package/dist/chunk-ABLJBL5P.js +2687 -0
- package/dist/chunk-ABLJBL5P.js.map +1 -0
- package/dist/chunk-BOOUADRN.js +1938 -0
- package/dist/chunk-BOOUADRN.js.map +1 -0
- package/dist/chunk-BPWIETN5.js +2983 -0
- package/dist/chunk-BPWIETN5.js.map +1 -0
- package/dist/chunk-CMQB5G7R.js +2968 -0
- package/dist/chunk-CMQB5G7R.js.map +1 -0
- package/dist/chunk-I4B53KZ7.js +2955 -0
- package/dist/chunk-I4B53KZ7.js.map +1 -0
- package/dist/chunk-IPNUAUAN.js +2991 -0
- package/dist/chunk-IPNUAUAN.js.map +1 -0
- package/dist/chunk-KZWL2S6E.js +2869 -0
- package/dist/chunk-KZWL2S6E.js.map +1 -0
- package/dist/chunk-LB2QBYW4.js +2686 -0
- package/dist/chunk-LB2QBYW4.js.map +1 -0
- package/dist/chunk-MR3AXSXL.js +1977 -0
- package/dist/chunk-MR3AXSXL.js.map +1 -0
- package/dist/chunk-NVSZXEZU.js +2688 -0
- package/dist/chunk-NVSZXEZU.js.map +1 -0
- package/dist/chunk-PD2WKAFE.js +2531 -0
- package/dist/chunk-PD2WKAFE.js.map +1 -0
- package/dist/chunk-PGY4ZDLD.js +1843 -0
- package/dist/chunk-PGY4ZDLD.js.map +1 -0
- package/dist/chunk-QDIAASH7.js +2982 -0
- package/dist/chunk-QDIAASH7.js.map +1 -0
- package/dist/chunk-UZWFLJGG.js +2873 -0
- package/dist/chunk-UZWFLJGG.js.map +1 -0
- package/dist/chunk-VFYTROYG.js +2825 -0
- package/dist/chunk-VFYTROYG.js.map +1 -0
- package/dist/chunk-ZTHKAJKT.js +2981 -0
- package/dist/chunk-ZTHKAJKT.js.map +1 -0
- package/dist/chunk-ZZULGRRE.js +2991 -0
- package/dist/chunk-ZZULGRRE.js.map +1 -0
- package/dist/index.d.ts +826 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,2982 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
import { TermwrightError } from "@termwright/driver";
|
|
3
|
+
|
|
4
|
+
// src/crash.ts
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
var CRASH_LIMITS = Object.freeze({
|
|
7
|
+
/** Screen-tail lines kept, newest end of the buffer. */
|
|
8
|
+
maxScreenTailLines: 40,
|
|
9
|
+
/** Characters kept per screen-tail line. */
|
|
10
|
+
maxLineChars: 500,
|
|
11
|
+
/** Recent inputs kept, newest last. */
|
|
12
|
+
maxInputs: 10,
|
|
13
|
+
/** Diagnostics entries kept, newest last. */
|
|
14
|
+
maxDiagnostics: 10
|
|
15
|
+
});
|
|
16
|
+
var crashSchema = z.object({
|
|
17
|
+
exit: z.object({ code: z.number().int().nullable(), signal: z.string().nullable() }),
|
|
18
|
+
timeMs: z.number(),
|
|
19
|
+
screenTail: z.array(z.string()).describe("what the terminal showed at the end, verbatim and unredacted \u2014 treat as sensitive"),
|
|
20
|
+
screenTailTruncated: z.boolean(),
|
|
21
|
+
lastSemanticRevision: z.number().int().nullable(),
|
|
22
|
+
recentInputs: z.array(
|
|
23
|
+
z.object({
|
|
24
|
+
timeMs: z.number(),
|
|
25
|
+
kind: z.enum(["key", "mouse", "paste", "raw"]),
|
|
26
|
+
bytes: z.number().int(),
|
|
27
|
+
preview: z.string().optional().describe("omitted for pastes, which routinely carry secrets")
|
|
28
|
+
})
|
|
29
|
+
),
|
|
30
|
+
diagnostics: z.array(
|
|
31
|
+
z.object({
|
|
32
|
+
// A free string on purpose — do not "fix" this into an enum. Tolerant
|
|
33
|
+
// reader, strict producer: the driver owns the closed code set and pins
|
|
34
|
+
// it with its own tests, while this consumer must survive a code it has
|
|
35
|
+
// never heard of. A closed enum here would let one unrecognised code
|
|
36
|
+
// fail the whole crash report, at the moment it is needed most.
|
|
37
|
+
code: z.string(),
|
|
38
|
+
detail: z.string(),
|
|
39
|
+
timeMs: z.number(),
|
|
40
|
+
revision: z.number().int().optional(),
|
|
41
|
+
mode: z.enum(["mouse", "focus"]).optional().describe('for "mode-unverifiable": which mode the platform hides')
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
});
|
|
45
|
+
function boundLines(lines) {
|
|
46
|
+
const kept = lines.slice(-CRASH_LIMITS.maxScreenTailLines);
|
|
47
|
+
return {
|
|
48
|
+
lines: kept.map((line) => line.length > CRASH_LIMITS.maxLineChars ? `${line.slice(0, CRASH_LIMITS.maxLineChars)}\u2026` : line),
|
|
49
|
+
truncated: kept.length < lines.length
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function describeCrash(report) {
|
|
53
|
+
const tail = boundLines(report.screenTail);
|
|
54
|
+
return {
|
|
55
|
+
exit: { code: report.exit.code, signal: report.exit.signal },
|
|
56
|
+
timeMs: report.timeMs,
|
|
57
|
+
screenTail: tail.lines,
|
|
58
|
+
screenTailTruncated: tail.truncated,
|
|
59
|
+
lastSemanticRevision: report.lastSemanticTree?.revision ?? null,
|
|
60
|
+
recentInputs: report.recentInputs.slice(-CRASH_LIMITS.maxInputs).map((input) => ({
|
|
61
|
+
timeMs: input.timeMs,
|
|
62
|
+
kind: input.kind,
|
|
63
|
+
bytes: input.bytes,
|
|
64
|
+
...input.preview === void 0 ? {} : { preview: input.preview }
|
|
65
|
+
})),
|
|
66
|
+
diagnostics: report.diagnosticsTail.slice(-CRASH_LIMITS.maxDiagnostics).map((entry) => ({
|
|
67
|
+
code: entry.code,
|
|
68
|
+
detail: entry.detail,
|
|
69
|
+
timeMs: entry.timeMs,
|
|
70
|
+
...entry.revision === void 0 ? {} : { revision: entry.revision },
|
|
71
|
+
...entry.mode === void 0 ? {} : { mode: entry.mode }
|
|
72
|
+
}))
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function renderCrash(crash) {
|
|
76
|
+
const lines = [
|
|
77
|
+
`crash: the program exited on its own \u2014 code=${String(crash.exit.code)} signal=${String(crash.exit.signal)} at ${crash.timeMs}ms`
|
|
78
|
+
];
|
|
79
|
+
if (crash.recentInputs.length > 0) {
|
|
80
|
+
const inputs = crash.recentInputs.map(
|
|
81
|
+
(input) => input.preview === void 0 ? `${input.kind}(${input.bytes}B)` : `${input.kind} ${JSON.stringify(input.preview)}`
|
|
82
|
+
);
|
|
83
|
+
lines.push(`last input: ${inputs.join(" ")}`);
|
|
84
|
+
}
|
|
85
|
+
for (const entry of crash.diagnostics) {
|
|
86
|
+
const mode = entry.mode === void 0 ? "" : ` (${entry.mode})`;
|
|
87
|
+
lines.push(`diagnostic ${entry.code}${mode}: ${entry.detail}`);
|
|
88
|
+
}
|
|
89
|
+
if (crash.screenTail.length > 0) {
|
|
90
|
+
lines.push(crash.screenTailTruncated ? "screen tail (truncated):" : "screen tail:");
|
|
91
|
+
lines.push(...crash.screenTail);
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
var CrashContextError = class extends Error {
|
|
96
|
+
cause;
|
|
97
|
+
crash;
|
|
98
|
+
constructor(cause, crash) {
|
|
99
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
100
|
+
this.name = "CrashContextError";
|
|
101
|
+
this.cause = cause;
|
|
102
|
+
this.crash = crash;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/errors.ts
|
|
107
|
+
var MCP_ERROR_KINDS = ["usage", "no-session", "internal"];
|
|
108
|
+
var EXIT_CODES = Object.freeze({
|
|
109
|
+
ok: 0,
|
|
110
|
+
assertion: 1,
|
|
111
|
+
usage: 2,
|
|
112
|
+
noSession: 3,
|
|
113
|
+
ipc: 4,
|
|
114
|
+
internal: 5
|
|
115
|
+
});
|
|
116
|
+
function exitCodeFor(kind) {
|
|
117
|
+
switch (kind) {
|
|
118
|
+
case "usage":
|
|
119
|
+
return EXIT_CODES.usage;
|
|
120
|
+
case "no-session":
|
|
121
|
+
case "session-closed":
|
|
122
|
+
return EXIT_CODES.noSession;
|
|
123
|
+
case "protocol-violation":
|
|
124
|
+
return EXIT_CODES.ipc;
|
|
125
|
+
case "internal":
|
|
126
|
+
return EXIT_CODES.internal;
|
|
127
|
+
default:
|
|
128
|
+
return EXIT_CODES.assertion;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
var McpError = class extends Error {
|
|
132
|
+
kind;
|
|
133
|
+
suggestion;
|
|
134
|
+
constructor(kind, message, suggestion) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "McpError";
|
|
137
|
+
this.kind = kind;
|
|
138
|
+
this.suggestion = suggestion;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
function usageError(message, suggestion) {
|
|
142
|
+
return new McpError("usage", message, suggestion);
|
|
143
|
+
}
|
|
144
|
+
function noSessionError(message, suggestion) {
|
|
145
|
+
return new McpError("no-session", message, suggestion);
|
|
146
|
+
}
|
|
147
|
+
var MAX_CANDIDATES = 10;
|
|
148
|
+
var MAX_EXCERPT_CHARS = 2e3;
|
|
149
|
+
function toErrorPayload(error) {
|
|
150
|
+
if (error instanceof CrashContextError) {
|
|
151
|
+
return { ...toErrorPayload(error.cause), crash: error.crash };
|
|
152
|
+
}
|
|
153
|
+
if (error instanceof TermwrightError) {
|
|
154
|
+
const diagnostics = error.diagnostics;
|
|
155
|
+
const candidates = diagnostics.candidates?.slice(0, MAX_CANDIDATES).map((candidate) => {
|
|
156
|
+
const role = candidate.role ?? "generic";
|
|
157
|
+
const name = candidate.name === void 0 ? "" : ` ${JSON.stringify(candidate.name)}`;
|
|
158
|
+
return `${role}${name} ref=${candidate.ref}`;
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
kind: error.code,
|
|
162
|
+
message: error.message,
|
|
163
|
+
...diagnostics.suggestion === void 0 ? {} : { suggestion: diagnostics.suggestion },
|
|
164
|
+
semanticTree: diagnostics.semanticTree,
|
|
165
|
+
...candidates === void 0 || candidates.length === 0 ? {} : { candidates },
|
|
166
|
+
...diagnostics.screenExcerpt === void 0 ? {} : { screenExcerpt: diagnostics.screenExcerpt.slice(0, MAX_EXCERPT_CHARS) }
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (error instanceof McpError) {
|
|
170
|
+
return {
|
|
171
|
+
kind: error.kind,
|
|
172
|
+
message: error.message,
|
|
173
|
+
...error.suggestion === void 0 ? {} : { suggestion: error.suggestion }
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { kind: "internal", message: error instanceof Error ? error.message : String(error) };
|
|
177
|
+
}
|
|
178
|
+
function renderErrorPayload(payload) {
|
|
179
|
+
const parts = [`error ${payload.kind}: ${payload.message}`];
|
|
180
|
+
if (payload.suggestion !== void 0) parts.push(`suggestion: ${payload.suggestion}`);
|
|
181
|
+
if (payload.semanticTree !== void 0) parts.push(`semanticTree: ${payload.semanticTree}`);
|
|
182
|
+
if (payload.candidates !== void 0) {
|
|
183
|
+
parts.push(`candidates:
|
|
184
|
+
${payload.candidates.map((candidate) => ` - ${candidate}`).join("\n")}`);
|
|
185
|
+
}
|
|
186
|
+
if (payload.screenExcerpt !== void 0) parts.push(`screen:
|
|
187
|
+
${payload.screenExcerpt}`);
|
|
188
|
+
if (payload.crash !== void 0) parts.push(renderCrash(payload.crash));
|
|
189
|
+
return parts.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/model.ts
|
|
193
|
+
import { SEMANTIC_ROLES } from "@termwright/protocol";
|
|
194
|
+
var FILTERABLE_STATES = [
|
|
195
|
+
"disabled",
|
|
196
|
+
"focused",
|
|
197
|
+
"selected",
|
|
198
|
+
"checked",
|
|
199
|
+
"expanded",
|
|
200
|
+
"modal",
|
|
201
|
+
"busy",
|
|
202
|
+
"hidden",
|
|
203
|
+
"readonly"
|
|
204
|
+
];
|
|
205
|
+
var SIGNALS = ["INT", "TERM", "KILL", "HUP"];
|
|
206
|
+
|
|
207
|
+
// src/traces.ts
|
|
208
|
+
import { stat } from "fs/promises";
|
|
209
|
+
import { openTrace, TraceError } from "@termwright/trace";
|
|
210
|
+
var TRACE_LIMITS = Object.freeze({
|
|
211
|
+
/** Archives kept open per MCP session; the least recently used is evicted. */
|
|
212
|
+
maxOpen: 8,
|
|
213
|
+
/** Refusal threshold for an archive, in bytes. */
|
|
214
|
+
maxArchiveBytes: 128 * 1024 * 1024,
|
|
215
|
+
/** Rows of reconstructed screen text a single frame may return. */
|
|
216
|
+
maxFrameRows: 200
|
|
217
|
+
});
|
|
218
|
+
function rethrowTraceError(error, path) {
|
|
219
|
+
if (error instanceof TraceError) {
|
|
220
|
+
throw new McpError(
|
|
221
|
+
error.code,
|
|
222
|
+
`${path}: ${error.message}`,
|
|
223
|
+
error.diagnostics.suggestion ?? "check that the path points at a .twtrace directory or zip"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
227
|
+
throw usageError(`no trace at ${path}`, "pass the path of a .twtrace directory or zip");
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
var TraceStore = class {
|
|
232
|
+
#open = /* @__PURE__ */ new Map();
|
|
233
|
+
#maxOpen;
|
|
234
|
+
#maxArchiveBytes;
|
|
235
|
+
#now;
|
|
236
|
+
#counter = 0;
|
|
237
|
+
constructor(options = {}) {
|
|
238
|
+
this.#maxOpen = options.maxOpen ?? TRACE_LIMITS.maxOpen;
|
|
239
|
+
this.#maxArchiveBytes = options.maxArchiveBytes ?? TRACE_LIMITS.maxArchiveBytes;
|
|
240
|
+
this.#now = options.now ?? Date.now;
|
|
241
|
+
}
|
|
242
|
+
/** Handles of every archive still open. */
|
|
243
|
+
list() {
|
|
244
|
+
return [...this.#open.values()];
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Opens an archive and registers it under a fresh `tr<n>` handle.
|
|
248
|
+
*
|
|
249
|
+
* At the ceiling the least recently used archive is closed rather than the
|
|
250
|
+
* call being refused: an agent can always re-open a path, but it cannot
|
|
251
|
+
* recover from a server that has wedged itself on old readers. The evicted
|
|
252
|
+
* handle is reported so the caller knows why it stopped working.
|
|
253
|
+
*/
|
|
254
|
+
async open(path) {
|
|
255
|
+
let size;
|
|
256
|
+
try {
|
|
257
|
+
const stats = await stat(path);
|
|
258
|
+
size = stats.isDirectory() ? 0 : stats.size;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
rethrowTraceError(error, path);
|
|
261
|
+
}
|
|
262
|
+
if (size > this.#maxArchiveBytes) {
|
|
263
|
+
throw new McpError(
|
|
264
|
+
"capacity",
|
|
265
|
+
`${path} is ${size} bytes; the ceiling is ${this.#maxArchiveBytes}`,
|
|
266
|
+
"open the archive with @termwright/trace directly, or re-record with tighter limits"
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
let reader;
|
|
270
|
+
try {
|
|
271
|
+
reader = await openTrace(path);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
rethrowTraceError(error, path);
|
|
274
|
+
}
|
|
275
|
+
const evicted = await this.#evictIfFull();
|
|
276
|
+
this.#counter += 1;
|
|
277
|
+
const trace = {
|
|
278
|
+
id: `tr${this.#counter}`,
|
|
279
|
+
path,
|
|
280
|
+
reader,
|
|
281
|
+
lastUsedAt: this.#now()
|
|
282
|
+
};
|
|
283
|
+
this.#open.set(trace.id, trace);
|
|
284
|
+
return { trace, evicted };
|
|
285
|
+
}
|
|
286
|
+
/** Looks up a handle and marks it as used. */
|
|
287
|
+
get(id) {
|
|
288
|
+
const trace = this.#open.get(id);
|
|
289
|
+
if (trace === void 0) {
|
|
290
|
+
const known = [...this.#open.keys()];
|
|
291
|
+
throw noSessionError(
|
|
292
|
+
`unknown trace ${JSON.stringify(id)}`,
|
|
293
|
+
known.length === 0 ? "open one with trace.open" : `open traces: ${known.join(", ")} (an evicted handle has to be re-opened by path)`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
trace.lastUsedAt = this.#now();
|
|
297
|
+
return trace;
|
|
298
|
+
}
|
|
299
|
+
async #evictIfFull() {
|
|
300
|
+
if (this.#open.size < this.#maxOpen) return null;
|
|
301
|
+
const coldest = [...this.#open.values()].reduce(
|
|
302
|
+
(oldest, candidate) => candidate.lastUsedAt < oldest.lastUsedAt ? candidate : oldest
|
|
303
|
+
);
|
|
304
|
+
this.#open.delete(coldest.id);
|
|
305
|
+
await coldest.reader.close();
|
|
306
|
+
return coldest.id;
|
|
307
|
+
}
|
|
308
|
+
/** Closes every open archive. Best-effort, so shutdown always completes. */
|
|
309
|
+
async closeAll() {
|
|
310
|
+
const traces = [...this.#open.values()];
|
|
311
|
+
this.#open.clear();
|
|
312
|
+
await Promise.all(
|
|
313
|
+
traces.map(async (trace) => {
|
|
314
|
+
try {
|
|
315
|
+
await trace.reader.close();
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// src/sessions.ts
|
|
324
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
325
|
+
import { tmpdir } from "os";
|
|
326
|
+
import { join } from "path";
|
|
327
|
+
import { launchTerminal } from "@termwright/driver";
|
|
328
|
+
import { DEFAULT_LIMITS } from "@termwright/protocol";
|
|
329
|
+
|
|
330
|
+
// src/logs.ts
|
|
331
|
+
import { z as z2 } from "zod";
|
|
332
|
+
var LOG_LIMITS = Object.freeze({
|
|
333
|
+
/** Entries retained per terminal; the oldest are evicted first. */
|
|
334
|
+
bufferSize: 1e3,
|
|
335
|
+
/** Entries returned by one call, newest kept. */
|
|
336
|
+
maxPerResponse: 100,
|
|
337
|
+
/** Characters kept per line or message. */
|
|
338
|
+
maxTextChars: 2e3
|
|
339
|
+
});
|
|
340
|
+
var logEntrySchema = z2.object({
|
|
341
|
+
seq: z2.number().int().describe("per-session counter assigned on arrival; the cursor for logs"),
|
|
342
|
+
timeMs: z2.number().describe("session clock; for a followed file, when the driver read the line"),
|
|
343
|
+
source: z2.enum(["file", "adapter"]),
|
|
344
|
+
label: z2.string().optional().describe("which log source, when the session follows more than one"),
|
|
345
|
+
level: z2.enum(["trace", "debug", "info", "warn", "error", "fatal"]).optional(),
|
|
346
|
+
message: z2.string().describe("the raw line, or the record\u2019s formatted message"),
|
|
347
|
+
logger: z2.string().optional(),
|
|
348
|
+
attrs: z2.record(z2.string(), z2.unknown()).optional(),
|
|
349
|
+
revision: z2.number().int().optional().describe("semantic revision current when the record was produced")
|
|
350
|
+
});
|
|
351
|
+
function clamp(text) {
|
|
352
|
+
return text.length > LOG_LIMITS.maxTextChars ? `${text.slice(0, LOG_LIMITS.maxTextChars)}\u2026` : text;
|
|
353
|
+
}
|
|
354
|
+
function toEntry(event, seq) {
|
|
355
|
+
const record = event.record;
|
|
356
|
+
return {
|
|
357
|
+
seq,
|
|
358
|
+
timeMs: event.timeMs,
|
|
359
|
+
source: event.source,
|
|
360
|
+
...event.label === void 0 ? {} : { label: event.label },
|
|
361
|
+
...record === void 0 ? { message: clamp(event.line ?? "") } : {
|
|
362
|
+
level: record.level,
|
|
363
|
+
message: clamp(record.message),
|
|
364
|
+
...record.logger === void 0 ? {} : { logger: record.logger },
|
|
365
|
+
...record.attrs === void 0 ? {} : { attrs: { ...record.attrs } },
|
|
366
|
+
...record.revision === void 0 ? {} : { revision: record.revision }
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
var LogBuffer = class {
|
|
371
|
+
#entries = [];
|
|
372
|
+
#capacity;
|
|
373
|
+
#counter = 0;
|
|
374
|
+
constructor(capacity = LOG_LIMITS.bufferSize) {
|
|
375
|
+
this.#capacity = capacity;
|
|
376
|
+
}
|
|
377
|
+
/** Sequence number of the newest entry; 0 when nothing has arrived. */
|
|
378
|
+
get sequence() {
|
|
379
|
+
return this.#counter;
|
|
380
|
+
}
|
|
381
|
+
/** Entries currently retained. */
|
|
382
|
+
get size() {
|
|
383
|
+
return this.#entries.length;
|
|
384
|
+
}
|
|
385
|
+
/** Records one driver event. */
|
|
386
|
+
append(event) {
|
|
387
|
+
this.#counter += 1;
|
|
388
|
+
this.#entries.push(toEntry(event, this.#counter));
|
|
389
|
+
if (this.#entries.length > this.#capacity) this.#entries.splice(0, this.#entries.length - this.#capacity);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Everything after `cursor`, newest-biased and bounded.
|
|
393
|
+
*
|
|
394
|
+
* A cursor older than the buffer is not an error: the entries in between are
|
|
395
|
+
* counted in {@link LogWindow.omitted} so an agent knows its view has a hole,
|
|
396
|
+
* rather than quietly seeing a shorter list.
|
|
397
|
+
*/
|
|
398
|
+
since(cursor, limit = LOG_LIMITS.maxPerResponse) {
|
|
399
|
+
const newer = this.#entries.filter((entry) => entry.seq > cursor);
|
|
400
|
+
const oldestKept = this.#entries[0]?.seq ?? this.#counter + 1;
|
|
401
|
+
const evicted = Math.max(0, Math.min(oldestKept - 1, this.#counter) - cursor);
|
|
402
|
+
const trimmed = newer.length > limit ? newer.slice(-limit) : newer;
|
|
403
|
+
return {
|
|
404
|
+
entries: trimmed,
|
|
405
|
+
omitted: evicted + (newer.length - trimmed.length),
|
|
406
|
+
cursor: this.#counter
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
function renderLogs(window) {
|
|
411
|
+
if (window.entries.length === 0 && window.omitted === 0) return "logs: none";
|
|
412
|
+
const header = window.omitted === 0 ? `logs: ${window.entries.length}` : `logs: ${window.entries.length} (${window.omitted} omitted \u2014 raise the limit or read more often)`;
|
|
413
|
+
return [
|
|
414
|
+
header,
|
|
415
|
+
...window.entries.map((entry) => {
|
|
416
|
+
const level = entry.level === void 0 ? "" : ` ${entry.level.toUpperCase()}`;
|
|
417
|
+
const label = entry.label === void 0 ? "" : ` [${entry.label}]`;
|
|
418
|
+
const logger = entry.logger === void 0 ? "" : ` ${entry.logger}:`;
|
|
419
|
+
return ` ${entry.timeMs}ms${level}${label}${logger} ${entry.message}`;
|
|
420
|
+
})
|
|
421
|
+
].join("\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/objects.ts
|
|
425
|
+
function definedOnly(value) {
|
|
426
|
+
const out = {};
|
|
427
|
+
for (const [key, item] of Object.entries(value)) {
|
|
428
|
+
if (item !== void 0) out[key] = item;
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/sessions.ts
|
|
434
|
+
var MCP_LIMITS = Object.freeze({
|
|
435
|
+
/** Concurrent MCP sessions. */
|
|
436
|
+
maxSessions: DEFAULT_LIMITS.maxSessions,
|
|
437
|
+
/** Concurrent terminals inside one MCP session. */
|
|
438
|
+
maxTerminals: DEFAULT_LIMITS.maxSessions,
|
|
439
|
+
/** Snapshots retained per terminal for `capture_since` cursors. */
|
|
440
|
+
maxHistory: 16,
|
|
441
|
+
/** Argument ceiling for a launch command line. */
|
|
442
|
+
maxCommandParts: 64
|
|
443
|
+
});
|
|
444
|
+
var TerminalStore = class {
|
|
445
|
+
sessionKey;
|
|
446
|
+
#directory;
|
|
447
|
+
#maxTerminals;
|
|
448
|
+
#now;
|
|
449
|
+
#terminals = /* @__PURE__ */ new Map();
|
|
450
|
+
#counter = 0;
|
|
451
|
+
constructor(options) {
|
|
452
|
+
this.sessionKey = options.sessionKey;
|
|
453
|
+
this.#directory = join(options.storageDir ?? join(tmpdir(), "termwright-mcp"), options.sessionKey);
|
|
454
|
+
this.#maxTerminals = options.maxTerminals ?? MCP_LIMITS.maxTerminals;
|
|
455
|
+
this.#now = options.now ?? Date.now;
|
|
456
|
+
}
|
|
457
|
+
/** Handles of every terminal still open in this session. */
|
|
458
|
+
list() {
|
|
459
|
+
return [...this.#terminals.values()];
|
|
460
|
+
}
|
|
461
|
+
/** Launches a child and registers it under a fresh `t<n>` handle. */
|
|
462
|
+
async launch(request) {
|
|
463
|
+
if (request.command.length === 0) throw usageError("command must have at least one element");
|
|
464
|
+
if (request.command.length > MCP_LIMITS.maxCommandParts) {
|
|
465
|
+
throw usageError(`command may have at most ${MCP_LIMITS.maxCommandParts} elements`);
|
|
466
|
+
}
|
|
467
|
+
if (this.#terminals.size >= this.#maxTerminals) {
|
|
468
|
+
throw new McpError(
|
|
469
|
+
"capacity",
|
|
470
|
+
`this session already owns ${this.#maxTerminals} terminals`,
|
|
471
|
+
"close a terminal with terminal.close before launching another"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const options = {
|
|
475
|
+
command: [...request.command],
|
|
476
|
+
// Environment policy belongs to the driver: 'replace' (its default) keeps
|
|
477
|
+
// the operator's secrets out of the child, 'inherit' is opt-in.
|
|
478
|
+
...request.env === void 0 ? {} : { env: request.env },
|
|
479
|
+
...request.envMode === void 0 ? {} : { envMode: request.envMode },
|
|
480
|
+
...request.cwd === void 0 ? {} : { cwd: request.cwd },
|
|
481
|
+
...request.columns === void 0 ? {} : { columns: request.columns },
|
|
482
|
+
...request.rows === void 0 ? {} : { rows: request.rows },
|
|
483
|
+
...request.scrollbackLines === void 0 ? {} : { scrollbackLines: request.scrollbackLines },
|
|
484
|
+
...request.semanticNegotiationMs === void 0 ? {} : { semanticNegotiationMs: request.semanticNegotiationMs },
|
|
485
|
+
...request.timeouts === void 0 ? {} : { timeouts: definedOnly(request.timeouts) },
|
|
486
|
+
...request.logs === void 0 ? {} : {
|
|
487
|
+
logs: request.logs.flatMap((source) => source.path === void 0 ? [] : [{
|
|
488
|
+
path: source.path,
|
|
489
|
+
...source.label === void 0 ? {} : { label: source.label }
|
|
490
|
+
}])
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const harness = await launchTerminal(options);
|
|
494
|
+
this.#counter += 1;
|
|
495
|
+
const id = `t${this.#counter}`;
|
|
496
|
+
const entry = {
|
|
497
|
+
id,
|
|
498
|
+
harness,
|
|
499
|
+
directory: join(this.#directory, id),
|
|
500
|
+
command: [...request.command],
|
|
501
|
+
exit: null,
|
|
502
|
+
closed: false,
|
|
503
|
+
history: [],
|
|
504
|
+
logs: new LogBuffer()
|
|
505
|
+
};
|
|
506
|
+
harness.events.on("app-log", (event) => {
|
|
507
|
+
entry.logs.append(event);
|
|
508
|
+
});
|
|
509
|
+
void harness.exit.then(
|
|
510
|
+
(status) => {
|
|
511
|
+
entry.exit = status;
|
|
512
|
+
},
|
|
513
|
+
() => {
|
|
514
|
+
entry.exit = { code: null, signal: null };
|
|
515
|
+
}
|
|
516
|
+
);
|
|
517
|
+
this.#terminals.set(id, entry);
|
|
518
|
+
return entry;
|
|
519
|
+
}
|
|
520
|
+
/** Looks up a handle without throwing; for callers that tolerate absence. */
|
|
521
|
+
find(id) {
|
|
522
|
+
return this.#terminals.get(id);
|
|
523
|
+
}
|
|
524
|
+
/** Looks up a handle; unknown or closed handles are a `no-session` failure. */
|
|
525
|
+
get(id) {
|
|
526
|
+
const entry = this.#terminals.get(id);
|
|
527
|
+
if (entry === void 0) {
|
|
528
|
+
const known = [...this.#terminals.keys()];
|
|
529
|
+
throw noSessionError(
|
|
530
|
+
`unknown terminal ${JSON.stringify(id)}`,
|
|
531
|
+
known.length === 0 ? "launch one with terminal.launch" : `open terminals: ${known.join(", ")}`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return entry;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Captures the current screen and semantic tree, and remembers it so a later
|
|
538
|
+
* `capture_since` can diff against this revision.
|
|
539
|
+
*/
|
|
540
|
+
record(entry) {
|
|
541
|
+
const screen = entry.harness.screen();
|
|
542
|
+
const semantic = entry.harness.semanticTree();
|
|
543
|
+
const record = {
|
|
544
|
+
revision: screen.revision,
|
|
545
|
+
semanticRevision: semantic?.revision ?? null,
|
|
546
|
+
rows: screen.text().split("\n"),
|
|
547
|
+
semantic,
|
|
548
|
+
logSeq: entry.logs.sequence,
|
|
549
|
+
capturedAt: this.#now()
|
|
550
|
+
};
|
|
551
|
+
entry.history = [...entry.history.filter((item) => item.revision !== record.revision), record].slice(
|
|
552
|
+
-MCP_LIMITS.maxHistory
|
|
553
|
+
);
|
|
554
|
+
return record;
|
|
555
|
+
}
|
|
556
|
+
/** The recorded baseline for a cursor, or a `history-truncated` failure. */
|
|
557
|
+
baseline(entry, cursor) {
|
|
558
|
+
const found = entry.history.find((item) => item.revision === cursor);
|
|
559
|
+
if (found !== void 0) return found;
|
|
560
|
+
const known = entry.history.map((item) => item.revision);
|
|
561
|
+
throw new McpError(
|
|
562
|
+
"history-truncated",
|
|
563
|
+
`no capture retained for cursor ${cursor}`,
|
|
564
|
+
known.length === 0 ? "take a terminal.snapshot first; its revision is the cursor" : `retained cursors: ${known.join(", ")}`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
/** Writes a full dump next to the session and returns its path. */
|
|
568
|
+
async writeDump(entry, name, contents) {
|
|
569
|
+
await mkdir(entry.directory, { recursive: true });
|
|
570
|
+
const path = join(entry.directory, name);
|
|
571
|
+
await writeFile(path, contents, "utf8");
|
|
572
|
+
return path;
|
|
573
|
+
}
|
|
574
|
+
/** Closes one terminal and forgets it. Idempotent. */
|
|
575
|
+
async close(id) {
|
|
576
|
+
const entry = this.get(id);
|
|
577
|
+
await entry.harness.close();
|
|
578
|
+
entry.closed = true;
|
|
579
|
+
this.#terminals.delete(id);
|
|
580
|
+
return entry;
|
|
581
|
+
}
|
|
582
|
+
/** Closes every terminal; failures are swallowed so shutdown always completes. */
|
|
583
|
+
async closeAll() {
|
|
584
|
+
const entries = [...this.#terminals.values()];
|
|
585
|
+
this.#terminals.clear();
|
|
586
|
+
await Promise.all(
|
|
587
|
+
entries.map(async (entry) => {
|
|
588
|
+
try {
|
|
589
|
+
await entry.harness.close();
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
entry.closed = true;
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
function createSessionStores(options) {
|
|
598
|
+
return {
|
|
599
|
+
terminals: new TerminalStore({
|
|
600
|
+
sessionKey: options.sessionKey,
|
|
601
|
+
...options.storageDir === void 0 ? {} : { storageDir: options.storageDir }
|
|
602
|
+
}),
|
|
603
|
+
traces: new TraceStore()
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
async function closeSessionStores(stores) {
|
|
607
|
+
await Promise.all([stores.terminals.closeAll(), stores.traces.closeAll()]);
|
|
608
|
+
}
|
|
609
|
+
var SessionRegistry = class {
|
|
610
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
611
|
+
#maxSessions;
|
|
612
|
+
#storageDir;
|
|
613
|
+
#idleTtlMs;
|
|
614
|
+
#now;
|
|
615
|
+
#disposeAttachment;
|
|
616
|
+
#onExpired;
|
|
617
|
+
#sweeper;
|
|
618
|
+
constructor(options = {}) {
|
|
619
|
+
this.#maxSessions = options.maxSessions ?? MCP_LIMITS.maxSessions;
|
|
620
|
+
this.#storageDir = options.storageDir;
|
|
621
|
+
this.#idleTtlMs = options.idleTtlMs ?? 0;
|
|
622
|
+
this.#now = options.now ?? Date.now;
|
|
623
|
+
this.#disposeAttachment = options.disposeAttachment;
|
|
624
|
+
this.#onExpired = options.onExpired;
|
|
625
|
+
}
|
|
626
|
+
/** The configured idle ceiling; `0` when expiry is disabled. */
|
|
627
|
+
get idleTtlMs() {
|
|
628
|
+
return this.#idleTtlMs;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Marks a session as used. Called for **every** request that names one, so a
|
|
632
|
+
* session stays alive exactly as long as someone is talking to it.
|
|
633
|
+
*/
|
|
634
|
+
touch(key) {
|
|
635
|
+
const session = this.#sessions.get(key);
|
|
636
|
+
if (session !== void 0) session.lastSeenAt = this.#now();
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Tears down every session idle past the TTL and returns their keys.
|
|
640
|
+
*
|
|
641
|
+
* Streamable HTTP has no disconnect signal: a client that crashes or walks
|
|
642
|
+
* away leaves its session, its terminals and their children running, and its
|
|
643
|
+
* slot taken. Repeated agent failures would then add up to an accidental
|
|
644
|
+
* denial of service against the operator's own machine, so idleness is the
|
|
645
|
+
* only honest liveness signal available here.
|
|
646
|
+
*/
|
|
647
|
+
async sweepIdle() {
|
|
648
|
+
if (this.#idleTtlMs <= 0) return [];
|
|
649
|
+
const deadline = this.#now() - this.#idleTtlMs;
|
|
650
|
+
const expired = [...this.#sessions.values()].filter((session) => session.lastSeenAt <= deadline).map((session) => session.key);
|
|
651
|
+
for (const key of expired) {
|
|
652
|
+
await this.delete(key);
|
|
653
|
+
this.#onExpired?.(key);
|
|
654
|
+
}
|
|
655
|
+
return expired;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Runs {@link sweepIdle} on a timer until the returned function is called.
|
|
659
|
+
* The timer is unref'd, so it never keeps a process alive on its own.
|
|
660
|
+
*/
|
|
661
|
+
startIdleSweeper(intervalMs = Math.min(Math.max(this.#idleTtlMs / 4, 1e3), 6e4)) {
|
|
662
|
+
if (this.#idleTtlMs <= 0) return () => void 0;
|
|
663
|
+
this.#sweeper = setInterval(() => {
|
|
664
|
+
void this.sweepIdle();
|
|
665
|
+
}, intervalMs);
|
|
666
|
+
this.#sweeper.unref?.();
|
|
667
|
+
return () => this.stopIdleSweeper();
|
|
668
|
+
}
|
|
669
|
+
/** Stops the sweeper started by {@link startIdleSweeper}. Idempotent. */
|
|
670
|
+
stopIdleSweeper() {
|
|
671
|
+
if (this.#sweeper === void 0) return;
|
|
672
|
+
clearInterval(this.#sweeper);
|
|
673
|
+
this.#sweeper = void 0;
|
|
674
|
+
}
|
|
675
|
+
get size() {
|
|
676
|
+
return this.#sessions.size;
|
|
677
|
+
}
|
|
678
|
+
/** True when another session would exceed the ceiling. */
|
|
679
|
+
get atCapacity() {
|
|
680
|
+
return this.#sessions.size >= this.#maxSessions;
|
|
681
|
+
}
|
|
682
|
+
/** Creates a session and its stores; throws `capacity` at the ceiling. */
|
|
683
|
+
create(key, attach) {
|
|
684
|
+
if (this.#sessions.has(key)) throw usageError(`session ${key} already exists`);
|
|
685
|
+
if (this.atCapacity) {
|
|
686
|
+
throw new McpError(
|
|
687
|
+
"capacity",
|
|
688
|
+
`server already serves ${this.#maxSessions} MCP sessions`,
|
|
689
|
+
"close an existing session (DELETE with its Mcp-Session-Id) and retry"
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
const stores = createSessionStores({ sessionKey: key, storageDir: this.#storageDir });
|
|
693
|
+
const session = {
|
|
694
|
+
key,
|
|
695
|
+
stores,
|
|
696
|
+
attachment: attach(stores),
|
|
697
|
+
lastSeenAt: this.#now()
|
|
698
|
+
};
|
|
699
|
+
this.#sessions.set(key, session);
|
|
700
|
+
return session;
|
|
701
|
+
}
|
|
702
|
+
get(key) {
|
|
703
|
+
return this.#sessions.get(key);
|
|
704
|
+
}
|
|
705
|
+
/** Removes a session and closes everything it owned. */
|
|
706
|
+
async delete(key) {
|
|
707
|
+
const session = this.#sessions.get(key);
|
|
708
|
+
if (session === void 0) return;
|
|
709
|
+
this.#sessions.delete(key);
|
|
710
|
+
await closeSessionStores(session.stores);
|
|
711
|
+
try {
|
|
712
|
+
await this.#disposeAttachment?.(session.attachment);
|
|
713
|
+
} catch {
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Closes every session and stops the sweeper. */
|
|
717
|
+
async closeAll() {
|
|
718
|
+
this.stopIdleSweeper();
|
|
719
|
+
const keys = [...this.#sessions.keys()];
|
|
720
|
+
await Promise.all(keys.map(async (key) => this.delete(key)));
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// src/tool-kit.ts
|
|
725
|
+
function defineTool(definition) {
|
|
726
|
+
return {
|
|
727
|
+
name: definition.name,
|
|
728
|
+
title: definition.title,
|
|
729
|
+
description: definition.description,
|
|
730
|
+
inputSchema: definition.inputSchema,
|
|
731
|
+
outputSchema: definition.outputSchema,
|
|
732
|
+
annotations: definition.annotations ?? {},
|
|
733
|
+
handler: definition.handler
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/screenshots.ts
|
|
738
|
+
import { LIGHT_THEME, renderPng } from "@termwright/screenshot";
|
|
739
|
+
var SCREENSHOT_LIMITS = Object.freeze({
|
|
740
|
+
/**
|
|
741
|
+
* Refusal threshold for one PNG, in bytes.
|
|
742
|
+
*
|
|
743
|
+
* An MCP result travels inside a JSON-RPC message, and base64 inflates it by
|
|
744
|
+
* a third: a screenshot larger than this is more likely to blow a context
|
|
745
|
+
* window than to answer a question, so it fails with a suggestion instead.
|
|
746
|
+
*/
|
|
747
|
+
maxPngBytes: 3 * 1024 * 1024,
|
|
748
|
+
/** Pixel density multiplier ceiling. */
|
|
749
|
+
maxScale: 3
|
|
750
|
+
});
|
|
751
|
+
function renderScreenshot(frame2, request = {}) {
|
|
752
|
+
const scale = request.scale ?? 1;
|
|
753
|
+
if (!Number.isFinite(scale) || scale <= 0 || scale > SCREENSHOT_LIMITS.maxScale) {
|
|
754
|
+
throw new McpError(
|
|
755
|
+
"usage",
|
|
756
|
+
`screenshotScale must be between 0 and ${SCREENSHOT_LIMITS.maxScale}, got ${scale}`,
|
|
757
|
+
"omit it for 1, or pass 2 for a retina-sharp image"
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
let rendered;
|
|
761
|
+
try {
|
|
762
|
+
rendered = renderPng(frame2, {
|
|
763
|
+
scale,
|
|
764
|
+
...request.theme === "light" ? { theme: LIGHT_THEME } : {}
|
|
765
|
+
});
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw new McpError(
|
|
768
|
+
"unsupported-action",
|
|
769
|
+
`the screenshot renderer failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
770
|
+
"omit screenshot \u2014 the tool still returns the screen as text and the compact tree"
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (rendered.png.byteLength > SCREENSHOT_LIMITS.maxPngBytes) {
|
|
774
|
+
throw new McpError(
|
|
775
|
+
"capacity",
|
|
776
|
+
`the PNG is ${rendered.png.byteLength} bytes; the ceiling is ${SCREENSHOT_LIMITS.maxPngBytes}`,
|
|
777
|
+
"lower screenshotScale, or resize the terminal before taking the screenshot"
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
data: Buffer.from(rendered.png).toString("base64"),
|
|
782
|
+
mimeType: "image/png",
|
|
783
|
+
width: rendered.width,
|
|
784
|
+
height: rendered.height,
|
|
785
|
+
selfContained: rendered.selfContained,
|
|
786
|
+
fallbackCharacters: [...rendered.fallbackCharacters]
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/format.ts
|
|
791
|
+
function formatRef(nodeId, revision) {
|
|
792
|
+
return `${nodeId}@${revision}`;
|
|
793
|
+
}
|
|
794
|
+
function parseRef(ref) {
|
|
795
|
+
const at = ref.lastIndexOf("@");
|
|
796
|
+
if (at <= 0) return null;
|
|
797
|
+
const nodeId = ref.slice(0, at);
|
|
798
|
+
const revision = Number(ref.slice(at + 1));
|
|
799
|
+
if (!Number.isInteger(revision) || revision < 0) return null;
|
|
800
|
+
return { nodeId, revision };
|
|
801
|
+
}
|
|
802
|
+
function formatBounds(bounds) {
|
|
803
|
+
return `(${bounds.row},${bounds.column},${bounds.width},${bounds.height})`;
|
|
804
|
+
}
|
|
805
|
+
function stateFlags(state) {
|
|
806
|
+
if (state === void 0) return [];
|
|
807
|
+
const flags = [];
|
|
808
|
+
for (const [key, value] of Object.entries(state)) {
|
|
809
|
+
if (value === void 0) continue;
|
|
810
|
+
if (value === true) flags.push(key);
|
|
811
|
+
else if (value === false) continue;
|
|
812
|
+
else flags.push(`${key}=${String(value)}`);
|
|
813
|
+
}
|
|
814
|
+
return flags;
|
|
815
|
+
}
|
|
816
|
+
function formatNodeLine(entry) {
|
|
817
|
+
const parts = [`${entry.role} ${JSON.stringify(entry.name)}`, `ref=${entry.ref}`];
|
|
818
|
+
if (entry.bounds !== void 0) parts.push(`bounds=${formatBounds(entry.bounds)}`);
|
|
819
|
+
return [...parts, ...entry.flags].join(" ");
|
|
820
|
+
}
|
|
821
|
+
function walkSnapshot(snapshot2) {
|
|
822
|
+
const children = /* @__PURE__ */ new Map();
|
|
823
|
+
const byId = /* @__PURE__ */ new Map();
|
|
824
|
+
for (const node of snapshot2.nodes) {
|
|
825
|
+
byId.set(node.id, node);
|
|
826
|
+
const parent = node.parentId;
|
|
827
|
+
if (parent === void 0) continue;
|
|
828
|
+
const bucket = children.get(parent);
|
|
829
|
+
if (bucket === void 0) children.set(parent, [node]);
|
|
830
|
+
else bucket.push(node);
|
|
831
|
+
}
|
|
832
|
+
const out = [];
|
|
833
|
+
const seen = /* @__PURE__ */ new Set();
|
|
834
|
+
const visit = (node, depth) => {
|
|
835
|
+
if (seen.has(node.id)) return;
|
|
836
|
+
seen.add(node.id);
|
|
837
|
+
out.push({ node, depth });
|
|
838
|
+
for (const child of children.get(node.id) ?? []) visit(child, depth + 1);
|
|
839
|
+
};
|
|
840
|
+
for (const rootId of snapshot2.rootIds) {
|
|
841
|
+
const root = byId.get(rootId);
|
|
842
|
+
if (root !== void 0) visit(root, 0);
|
|
843
|
+
}
|
|
844
|
+
for (const node of snapshot2.nodes) visit(node, 0);
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
function refEntries(snapshot2) {
|
|
848
|
+
return walkSnapshot(snapshot2).map(({ node, depth }) => toRefEntry(node, snapshot2.revision, depth));
|
|
849
|
+
}
|
|
850
|
+
function toRefEntry(node, revision, depth = 0) {
|
|
851
|
+
return {
|
|
852
|
+
ref: formatRef(node.id, revision),
|
|
853
|
+
role: node.role,
|
|
854
|
+
name: node.name,
|
|
855
|
+
depth,
|
|
856
|
+
...node.bounds === void 0 ? {} : { bounds: node.bounds },
|
|
857
|
+
flags: stateFlags(node.state),
|
|
858
|
+
...node.testId === void 0 ? {} : { testId: node.testId },
|
|
859
|
+
...node.value === void 0 ? {} : { value: node.value }
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
var DEFAULT_MAX_NODES = 500;
|
|
863
|
+
function formatCompactSnapshot(options) {
|
|
864
|
+
const lines = [
|
|
865
|
+
`Terminal ${options.terminal} ${options.columns}x${options.rows} revision ${options.revision}`,
|
|
866
|
+
`semanticTree: ${options.semantic === null ? "unavailable" : "available"}`
|
|
867
|
+
];
|
|
868
|
+
if (options.semantic !== null) {
|
|
869
|
+
const entries = refEntries(options.semantic);
|
|
870
|
+
const limit = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
871
|
+
for (const entry of entries.slice(0, limit)) {
|
|
872
|
+
lines.push(`${" ".repeat(entry.depth)}${formatNodeLine(entry)}`);
|
|
873
|
+
}
|
|
874
|
+
if (entries.length > limit) {
|
|
875
|
+
lines.push(`... ${entries.length - limit} more nodes (raise maxNodes or use variant="full")`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (options.includeText !== false) {
|
|
879
|
+
lines.push("visible text:");
|
|
880
|
+
const maxRows = options.maxRows ?? options.text.length;
|
|
881
|
+
lines.push(...options.text.slice(0, maxRows));
|
|
882
|
+
if (options.text.length > maxRows) lines.push(`... ${options.text.length - maxRows} more rows`);
|
|
883
|
+
}
|
|
884
|
+
return lines.join("\n");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/diff.ts
|
|
888
|
+
function diffRows(before, after) {
|
|
889
|
+
const changes = [];
|
|
890
|
+
for (let row = 0; row < after.length; row += 1) {
|
|
891
|
+
const next = after[row] ?? "";
|
|
892
|
+
if (before[row] !== next) changes.push({ row, text: next });
|
|
893
|
+
}
|
|
894
|
+
for (let row = after.length; row < before.length; row += 1) {
|
|
895
|
+
changes.push({ row, text: "" });
|
|
896
|
+
}
|
|
897
|
+
return changes;
|
|
898
|
+
}
|
|
899
|
+
function nodeChanged(before, after) {
|
|
900
|
+
return before.role !== after.role || before.name !== after.name || before.value !== after.value || before.testId !== after.testId || before.description !== after.description || before.parentId !== after.parentId || JSON.stringify(before.bounds ?? null) !== JSON.stringify(after.bounds ?? null) || JSON.stringify(before.state ?? null) !== JSON.stringify(after.state ?? null);
|
|
901
|
+
}
|
|
902
|
+
function indexById(snapshot2) {
|
|
903
|
+
const index = /* @__PURE__ */ new Map();
|
|
904
|
+
for (const node of snapshot2?.nodes ?? []) index.set(node.id, node);
|
|
905
|
+
return index;
|
|
906
|
+
}
|
|
907
|
+
function renderSubtree(snapshot2, rootId, walked) {
|
|
908
|
+
const rootIndex = walked.findIndex(({ node }) => node.id === rootId);
|
|
909
|
+
const rootEntry = walked[rootIndex];
|
|
910
|
+
if (rootEntry === void 0) throw new Error(`node ${rootId} vanished mid-diff`);
|
|
911
|
+
const lines = [];
|
|
912
|
+
let root;
|
|
913
|
+
for (let i = rootIndex; i < walked.length; i += 1) {
|
|
914
|
+
const current = walked[i];
|
|
915
|
+
if (current === void 0) break;
|
|
916
|
+
if (i > rootIndex && current.depth <= rootEntry.depth) break;
|
|
917
|
+
const entry = toRefEntry(current.node, snapshot2.revision, current.depth - rootEntry.depth);
|
|
918
|
+
root ??= entry;
|
|
919
|
+
lines.push(`${" ".repeat(entry.depth)}${formatNodeLine(entry)}`);
|
|
920
|
+
}
|
|
921
|
+
return { root: root ?? toRefEntry(rootEntry.node, snapshot2.revision), compact: lines.join("\n") };
|
|
922
|
+
}
|
|
923
|
+
function diffSemantic(before, after) {
|
|
924
|
+
if (after === null) {
|
|
925
|
+
if (before === null) return [];
|
|
926
|
+
return before.nodes.map((node) => ({
|
|
927
|
+
change: "removed",
|
|
928
|
+
ref: formatRef(node.id, before.revision),
|
|
929
|
+
role: node.role,
|
|
930
|
+
name: node.name,
|
|
931
|
+
compact: formatNodeLine(toRefEntry(node, before.revision))
|
|
932
|
+
}));
|
|
933
|
+
}
|
|
934
|
+
const beforeIndex = indexById(before);
|
|
935
|
+
const afterIndex = indexById(after);
|
|
936
|
+
const walked = walkSnapshot(after);
|
|
937
|
+
const changedIds = /* @__PURE__ */ new Set();
|
|
938
|
+
for (const node of after.nodes) {
|
|
939
|
+
const previous = beforeIndex.get(node.id);
|
|
940
|
+
if (previous === void 0 || nodeChanged(previous, node)) changedIds.add(node.id);
|
|
941
|
+
}
|
|
942
|
+
const changes = [];
|
|
943
|
+
for (const { node } of walked) {
|
|
944
|
+
if (!changedIds.has(node.id)) continue;
|
|
945
|
+
const parentId = node.parentId;
|
|
946
|
+
if (parentId !== void 0 && changedIds.has(parentId) && afterIndex.has(parentId)) continue;
|
|
947
|
+
const rendered = renderSubtree(after, node.id, walked);
|
|
948
|
+
changes.push({
|
|
949
|
+
change: beforeIndex.has(node.id) ? "updated" : "added",
|
|
950
|
+
ref: rendered.root.ref,
|
|
951
|
+
role: node.role,
|
|
952
|
+
name: node.name,
|
|
953
|
+
compact: rendered.compact
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
for (const node of before?.nodes ?? []) {
|
|
957
|
+
if (afterIndex.has(node.id)) continue;
|
|
958
|
+
const parentId = node.parentId;
|
|
959
|
+
if (parentId !== void 0 && !afterIndex.has(parentId)) continue;
|
|
960
|
+
changes.push({
|
|
961
|
+
change: "removed",
|
|
962
|
+
ref: formatRef(node.id, before?.revision ?? 0),
|
|
963
|
+
role: node.role,
|
|
964
|
+
name: node.name,
|
|
965
|
+
compact: formatNodeLine(toRefEntry(node, before?.revision ?? 0))
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
return changes;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/targets.ts
|
|
972
|
+
var REGEX_LITERAL = /^\/(.*)\/([gimsuy]*)$/su;
|
|
973
|
+
function textOrRegExp(value) {
|
|
974
|
+
const match = REGEX_LITERAL.exec(value);
|
|
975
|
+
if (match === null) return value;
|
|
976
|
+
try {
|
|
977
|
+
return new RegExp(match[1] ?? "", match[2] ?? "");
|
|
978
|
+
} catch (error) {
|
|
979
|
+
throw usageError(
|
|
980
|
+
`invalid regular expression ${JSON.stringify(value)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
981
|
+
"quote a literal string, or fix the /pattern/flags form"
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function hasTarget(input) {
|
|
986
|
+
return input.ref !== void 0 || input.selector !== void 0 || input.role !== void 0 || input.testId !== void 0 || input.text !== void 0 || input.label !== void 0;
|
|
987
|
+
}
|
|
988
|
+
function buildLocator(harness, input) {
|
|
989
|
+
let locator;
|
|
990
|
+
if (input.ref !== void 0) {
|
|
991
|
+
locator = harness.locatorForRef(input.ref);
|
|
992
|
+
} else if (input.selector !== void 0) {
|
|
993
|
+
locator = harness.locator(input.selector);
|
|
994
|
+
} else if (input.testId !== void 0) {
|
|
995
|
+
locator = harness.getByTestId(input.testId);
|
|
996
|
+
} else if (input.role !== void 0) {
|
|
997
|
+
locator = harness.getByRole(
|
|
998
|
+
input.role,
|
|
999
|
+
definedOnly({
|
|
1000
|
+
name: input.name === void 0 ? void 0 : textOrRegExp(input.name),
|
|
1001
|
+
exact: input.exact,
|
|
1002
|
+
state: input.state === void 0 ? void 0 : definedOnly(input.state)
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
} else if (input.label !== void 0) {
|
|
1006
|
+
locator = harness.getByLabel(textOrRegExp(input.label), definedOnly({ exact: input.exact }));
|
|
1007
|
+
} else if (input.text !== void 0) {
|
|
1008
|
+
locator = harness.getByText(textOrRegExp(input.text), definedOnly({ exact: input.exact }));
|
|
1009
|
+
} else {
|
|
1010
|
+
throw usageError(
|
|
1011
|
+
"no target given",
|
|
1012
|
+
"pass one of ref, selector, testId, role (+name), label or text"
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
return input.nth === void 0 ? locator : locator.nth(input.nth);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/tools.ts
|
|
1019
|
+
import { z as z5 } from "zod";
|
|
1020
|
+
|
|
1021
|
+
// src/screenshot-schema.ts
|
|
1022
|
+
import { z as z3 } from "zod";
|
|
1023
|
+
var screenshotSchema = z3.object({
|
|
1024
|
+
width: z3.number().int(),
|
|
1025
|
+
height: z3.number().int(),
|
|
1026
|
+
mimeType: z3.literal("image/png"),
|
|
1027
|
+
selfContained: z3.boolean().describe("false when a character had no embedded outline and fell back to a font"),
|
|
1028
|
+
fallbackCharacters: z3.array(z3.string())
|
|
1029
|
+
});
|
|
1030
|
+
function describeImage(image) {
|
|
1031
|
+
return {
|
|
1032
|
+
width: image.width,
|
|
1033
|
+
height: image.height,
|
|
1034
|
+
mimeType: image.mimeType,
|
|
1035
|
+
selfContained: image.selfContained,
|
|
1036
|
+
fallbackCharacters: [...image.fallbackCharacters]
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/schemas.ts
|
|
1041
|
+
import { z as z4 } from "zod";
|
|
1042
|
+
var terminalId = z4.string().min(1).describe('terminal handle returned by terminal.launch, e.g. "t1"');
|
|
1043
|
+
var timeoutMs = z4.number().int().positive().max(6e5).describe("timeout in milliseconds; defaults to the driver timeout class for the action");
|
|
1044
|
+
var stateFilter = z4.object({
|
|
1045
|
+
disabled: z4.boolean().optional(),
|
|
1046
|
+
focused: z4.boolean().optional(),
|
|
1047
|
+
selected: z4.boolean().optional(),
|
|
1048
|
+
checked: z4.union([z4.boolean(), z4.literal("mixed")]).optional(),
|
|
1049
|
+
expanded: z4.boolean().optional(),
|
|
1050
|
+
modal: z4.boolean().optional(),
|
|
1051
|
+
busy: z4.boolean().optional(),
|
|
1052
|
+
hidden: z4.boolean().optional(),
|
|
1053
|
+
readonly: z4.boolean().optional()
|
|
1054
|
+
}).describe("only nodes asserting these state flags match");
|
|
1055
|
+
var cellPosition = z4.object({
|
|
1056
|
+
row: z4.number().int().min(0),
|
|
1057
|
+
column: z4.number().int().min(0)
|
|
1058
|
+
});
|
|
1059
|
+
var targetShape = {
|
|
1060
|
+
ref: z4.string().optional().describe(
|
|
1061
|
+
'ref from a previous snapshot: "n8@42" (semantic node) or "grid:1,2,9,1@7" (grid match); valid only at the revision it was minted at'
|
|
1062
|
+
),
|
|
1063
|
+
selector: z4.string().optional().describe('CSS dialect: "dialog button#approve:focused" (role, #testId, .class, :state)'),
|
|
1064
|
+
role: z4.enum(SEMANTIC_ROLES).optional().describe("semantic role; requires a semantic tree"),
|
|
1065
|
+
name: z4.string().optional().describe('accessible name; "/pattern/flags" is read as a regular expression'),
|
|
1066
|
+
testId: z4.string().optional().describe("author-supplied test id"),
|
|
1067
|
+
label: z4.string().optional().describe("label text (labelledBy, else name)"),
|
|
1068
|
+
text: z4.string().optional().describe("visible text; matches the grid when there is no semantic tree"),
|
|
1069
|
+
exact: z4.boolean().optional().describe("exact rather than substring text matching"),
|
|
1070
|
+
state: stateFilter.optional(),
|
|
1071
|
+
nth: z4.number().int().min(0).optional().describe("zero-based pick among matches; omit for strict mode (>1 match fails)")
|
|
1072
|
+
};
|
|
1073
|
+
var targetShapeWithoutText = (({ text: _text, ...rest }) => rest)(targetShape);
|
|
1074
|
+
var targetObject = z4.object(targetShape);
|
|
1075
|
+
var refEntrySchema = z4.object({
|
|
1076
|
+
ref: z4.string(),
|
|
1077
|
+
role: z4.string(),
|
|
1078
|
+
name: z4.string(),
|
|
1079
|
+
depth: z4.number().int().min(0),
|
|
1080
|
+
bounds: z4.object({
|
|
1081
|
+
row: z4.number().int(),
|
|
1082
|
+
column: z4.number().int(),
|
|
1083
|
+
width: z4.number().int(),
|
|
1084
|
+
height: z4.number().int()
|
|
1085
|
+
}).optional(),
|
|
1086
|
+
flags: z4.array(z4.string()),
|
|
1087
|
+
testId: z4.string().optional(),
|
|
1088
|
+
value: z4.string().optional()
|
|
1089
|
+
});
|
|
1090
|
+
var semanticTreeState = z4.enum(["available", "unavailable"]);
|
|
1091
|
+
var cursorSchema = z4.object({
|
|
1092
|
+
row: z4.number().int(),
|
|
1093
|
+
column: z4.number().int(),
|
|
1094
|
+
visible: z4.boolean(),
|
|
1095
|
+
shape: z4.enum(["block", "underline", "bar"]).optional()
|
|
1096
|
+
});
|
|
1097
|
+
var modesSchema = z4.object({
|
|
1098
|
+
/**
|
|
1099
|
+
* `'unknown'` means the platform hides the mode from the emulator (ConPTY on
|
|
1100
|
+
* Windows), not that the program disabled mouse reporting. Pointer actions
|
|
1101
|
+
* still work there: input goes out as SGR, which every program that enables
|
|
1102
|
+
* mouse reporting understands.
|
|
1103
|
+
*/
|
|
1104
|
+
mouseTracking: z4.enum(["none", "x10", "vt200", "drag", "any", "unknown"]),
|
|
1105
|
+
mouseEncoding: z4.enum(["default", "sgr", "urxvt", "utf8", "unknown"]),
|
|
1106
|
+
bracketedPaste: z4.boolean(),
|
|
1107
|
+
applicationCursorKeys: z4.boolean(),
|
|
1108
|
+
applicationKeypad: z4.boolean(),
|
|
1109
|
+
/**
|
|
1110
|
+
* `'unknown'` has the same meaning as for the mouse fields: the platform
|
|
1111
|
+
* hides the mode, so the emulator cannot say whether the program asked for
|
|
1112
|
+
* focus events. It is not `'off'`.
|
|
1113
|
+
*/
|
|
1114
|
+
focusReporting: z4.enum(["on", "off", "unknown"]),
|
|
1115
|
+
synchronizedOutput: z4.boolean()
|
|
1116
|
+
});
|
|
1117
|
+
var exitSchema = z4.object({
|
|
1118
|
+
code: z4.number().int().nullable(),
|
|
1119
|
+
signal: z4.string().nullable()
|
|
1120
|
+
});
|
|
1121
|
+
var signalSchema = z4.enum(SIGNALS);
|
|
1122
|
+
var buttonSchema = z4.enum(["left", "middle", "right"]);
|
|
1123
|
+
var roleEnum = z4.enum(SEMANTIC_ROLES);
|
|
1124
|
+
var STATE_NAMES = FILTERABLE_STATES;
|
|
1125
|
+
|
|
1126
|
+
// src/tools.ts
|
|
1127
|
+
var semanticFields = {
|
|
1128
|
+
terminal: z5.string(),
|
|
1129
|
+
revision: z5.number().int().describe("screen revision; pass it to terminal.capture_since as cursor"),
|
|
1130
|
+
semanticRevision: z5.number().int().nullable(),
|
|
1131
|
+
semanticTree: semanticTreeState
|
|
1132
|
+
};
|
|
1133
|
+
function treeState(available) {
|
|
1134
|
+
return available ? "available" : "unavailable";
|
|
1135
|
+
}
|
|
1136
|
+
function optionalTimeout(timeout) {
|
|
1137
|
+
return timeout === void 0 ? {} : { timeout };
|
|
1138
|
+
}
|
|
1139
|
+
async function settleSemantics(entry) {
|
|
1140
|
+
if (!entry.harness.capabilities().semanticTree) return;
|
|
1141
|
+
const budget = entry.harness.semanticTree() === null ? FIRST_TREE_SETTLE_MS : PAIRING_SETTLE_MS;
|
|
1142
|
+
try {
|
|
1143
|
+
await entry.harness.waitForStable({ timeout: budget });
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
var FIRST_TREE_SETTLE_MS = 2e3;
|
|
1148
|
+
var PAIRING_SETTLE_MS = 250;
|
|
1149
|
+
function capture(context, entry) {
|
|
1150
|
+
const record = context.terminals.record(entry);
|
|
1151
|
+
return {
|
|
1152
|
+
rows: record.rows,
|
|
1153
|
+
refs: record.semantic === null ? [] : refEntries(record.semantic),
|
|
1154
|
+
revision: record.revision,
|
|
1155
|
+
semanticRevision: record.semanticRevision
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function compactFor(entry, rows, options = {}) {
|
|
1159
|
+
const screen = entry.harness.screen();
|
|
1160
|
+
return formatCompactSnapshot({
|
|
1161
|
+
terminal: entry.id,
|
|
1162
|
+
columns: screen.columns,
|
|
1163
|
+
rows: screen.rows,
|
|
1164
|
+
revision: screen.revision,
|
|
1165
|
+
semantic: entry.harness.semanticTree(),
|
|
1166
|
+
text: rows,
|
|
1167
|
+
...options
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
function locatorFor(entry, args) {
|
|
1171
|
+
return buildLocator(entry.harness, args);
|
|
1172
|
+
}
|
|
1173
|
+
function crashOf(entry) {
|
|
1174
|
+
const report = entry.harness.crashReport();
|
|
1175
|
+
return report === null ? void 0 : describeCrash(report);
|
|
1176
|
+
}
|
|
1177
|
+
var receiptFields = {
|
|
1178
|
+
ok: z5.literal(true),
|
|
1179
|
+
terminal: z5.string(),
|
|
1180
|
+
revision: z5.number().int()
|
|
1181
|
+
};
|
|
1182
|
+
function receipt(entry) {
|
|
1183
|
+
return { ok: true, terminal: entry.id, revision: entry.harness.screen().revision };
|
|
1184
|
+
}
|
|
1185
|
+
var launch = defineTool({
|
|
1186
|
+
name: "terminal.launch",
|
|
1187
|
+
title: "Launch a terminal",
|
|
1188
|
+
description: 'Starts a program in a real pseudo-terminal and returns a terminal handle plus the first snapshot. The child gets a minimal environment unless envMode is "inherit"; values passed in env are never echoed back.',
|
|
1189
|
+
inputSchema: {
|
|
1190
|
+
command: z5.array(z5.string()).min(1).describe('argv, e.g. ["node", "app.js"] \u2014 no shell is involved'),
|
|
1191
|
+
cwd: z5.string().optional(),
|
|
1192
|
+
env: z5.record(z5.string(), z5.string()).optional().describe("extra environment for the child"),
|
|
1193
|
+
envMode: z5.enum(["replace", "inherit"]).optional().describe(
|
|
1194
|
+
'default "replace": the child gets PATH, HOME, LANG, LC_ALL, SHELL, TMPDIR, USER, TERM plus env. "inherit" hands it the whole server environment'
|
|
1195
|
+
),
|
|
1196
|
+
columns: z5.number().int().min(1).max(1e3).optional().describe("default 100"),
|
|
1197
|
+
rows: z5.number().int().min(1).max(1e3).optional().describe("default 30"),
|
|
1198
|
+
scrollbackLines: z5.number().int().min(0).max(1e5).optional(),
|
|
1199
|
+
semanticNegotiationMs: z5.number().int().min(0).max(6e4).optional(),
|
|
1200
|
+
logs: z5.array(
|
|
1201
|
+
z5.object({
|
|
1202
|
+
path: z5.string().min(1).describe("log file to follow for the life of the session"),
|
|
1203
|
+
label: z5.string().optional().describe("short name shown on each entry")
|
|
1204
|
+
})
|
|
1205
|
+
).max(8).optional().describe(
|
|
1206
|
+
"application log files to follow. An existing file is followed from its end, so a session never replays a previous run; a missing one is waited for"
|
|
1207
|
+
),
|
|
1208
|
+
timeouts: z5.object({
|
|
1209
|
+
action: z5.number().int().positive().optional(),
|
|
1210
|
+
text: z5.number().int().positive().optional(),
|
|
1211
|
+
idle: z5.number().int().positive().optional(),
|
|
1212
|
+
ready: z5.number().int().positive().optional(),
|
|
1213
|
+
exit: z5.number().int().positive().optional()
|
|
1214
|
+
}).optional()
|
|
1215
|
+
},
|
|
1216
|
+
outputSchema: {
|
|
1217
|
+
...semanticFields,
|
|
1218
|
+
sessionId: z5.string(),
|
|
1219
|
+
columns: z5.number().int(),
|
|
1220
|
+
rows: z5.number().int(),
|
|
1221
|
+
adapter: z5.object({ name: z5.string(), version: z5.string() }).optional(),
|
|
1222
|
+
capabilities: z5.array(z5.string()),
|
|
1223
|
+
platform: z5.string(),
|
|
1224
|
+
compact: z5.string()
|
|
1225
|
+
},
|
|
1226
|
+
annotations: { openWorldHint: true },
|
|
1227
|
+
handler: async (context, args) => {
|
|
1228
|
+
const entry = await context.terminals.launch(args);
|
|
1229
|
+
await settleSemantics(entry);
|
|
1230
|
+
const capabilities2 = entry.harness.capabilities();
|
|
1231
|
+
const screen = entry.harness.screen();
|
|
1232
|
+
const state = capture(context, entry);
|
|
1233
|
+
const compact = compactFor(entry, state.rows);
|
|
1234
|
+
return {
|
|
1235
|
+
text: compact,
|
|
1236
|
+
data: {
|
|
1237
|
+
terminal: entry.id,
|
|
1238
|
+
sessionId: entry.harness.sessionId,
|
|
1239
|
+
revision: state.revision,
|
|
1240
|
+
semanticRevision: state.semanticRevision,
|
|
1241
|
+
semanticTree: treeState(capabilities2.semanticTree),
|
|
1242
|
+
columns: screen.columns,
|
|
1243
|
+
rows: screen.rows,
|
|
1244
|
+
...capabilities2.adapter === void 0 ? {} : { adapter: capabilities2.adapter },
|
|
1245
|
+
capabilities: [...capabilities2.capabilities],
|
|
1246
|
+
platform: capabilities2.platform,
|
|
1247
|
+
compact
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
var capabilities = defineTool({
|
|
1253
|
+
name: "terminal.capabilities",
|
|
1254
|
+
title: "Session capabilities",
|
|
1255
|
+
description: "What this session supports: whether a semantic tree is published, which adapter publishes it, and the terminal geometry. Call it before relying on role-based targeting.",
|
|
1256
|
+
inputSchema: { terminal: terminalId },
|
|
1257
|
+
outputSchema: {
|
|
1258
|
+
...semanticFields,
|
|
1259
|
+
columns: z5.number().int(),
|
|
1260
|
+
rows: z5.number().int(),
|
|
1261
|
+
adapter: z5.object({ name: z5.string(), version: z5.string() }).optional(),
|
|
1262
|
+
capabilities: z5.array(z5.string()),
|
|
1263
|
+
platform: z5.string(),
|
|
1264
|
+
crash: crashSchema.optional()
|
|
1265
|
+
},
|
|
1266
|
+
annotations: { readOnlyHint: true },
|
|
1267
|
+
handler: async (context, args) => {
|
|
1268
|
+
const entry = context.terminals.get(args.terminal);
|
|
1269
|
+
await settleSemantics(entry);
|
|
1270
|
+
const caps = entry.harness.capabilities();
|
|
1271
|
+
const crash = crashOf(entry);
|
|
1272
|
+
const screen = entry.harness.screen();
|
|
1273
|
+
const semantic = entry.harness.semanticTree();
|
|
1274
|
+
return {
|
|
1275
|
+
text: `Terminal ${entry.id} ${screen.columns}x${screen.rows} revision ${screen.revision}
|
|
1276
|
+
semanticTree: ${caps.semanticTree ? "available" : "unavailable"}
|
|
1277
|
+
adapter: ${caps.adapter === void 0 ? "none" : `${caps.adapter.name} ${caps.adapter.version}`}
|
|
1278
|
+
capabilities: ${caps.capabilities.join(", ") || "none"}
|
|
1279
|
+
platform: ${caps.platform}` + (crash === void 0 ? "" : `
|
|
1280
|
+
${renderCrash(crash)}`),
|
|
1281
|
+
data: {
|
|
1282
|
+
terminal: entry.id,
|
|
1283
|
+
revision: screen.revision,
|
|
1284
|
+
semanticRevision: semantic?.revision ?? null,
|
|
1285
|
+
semanticTree: treeState(caps.semanticTree),
|
|
1286
|
+
columns: screen.columns,
|
|
1287
|
+
rows: screen.rows,
|
|
1288
|
+
...caps.adapter === void 0 ? {} : { adapter: caps.adapter },
|
|
1289
|
+
capabilities: [...caps.capabilities],
|
|
1290
|
+
platform: caps.platform,
|
|
1291
|
+
...crash === void 0 ? {} : { crash }
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
var snapshot = defineTool({
|
|
1297
|
+
name: "terminal.snapshot",
|
|
1298
|
+
title: "Snapshot the terminal",
|
|
1299
|
+
description: 'One typed view of the terminal: compact semantic refs, visible text, cursor, terminal modes and scroll position. variant "full" writes the complete dump (text, ANSI, HTML, semantic tree) to disk and returns only refs plus the file path. The returned revision is the cursor for terminal.capture_since.',
|
|
1300
|
+
inputSchema: {
|
|
1301
|
+
terminal: terminalId,
|
|
1302
|
+
variant: z5.enum(["compact", "full"]).optional().describe('default "compact"'),
|
|
1303
|
+
maxNodes: z5.number().int().min(1).max(5e3).optional(),
|
|
1304
|
+
maxRows: z5.number().int().min(1).max(1e4).optional(),
|
|
1305
|
+
includeText: z5.boolean().optional().describe("include the visible grid text (default true)"),
|
|
1306
|
+
screenshot: z5.boolean().optional().describe("also attach a PNG of the screen, rendered without a browser"),
|
|
1307
|
+
screenshotScale: z5.number().min(0.1).max(3).optional().describe("pixel density of the PNG; default 1, 2 is retina-sharp"),
|
|
1308
|
+
screenshotTheme: z5.enum(["dark", "light"]).optional().describe("PNG background; default dark")
|
|
1309
|
+
},
|
|
1310
|
+
outputSchema: {
|
|
1311
|
+
...semanticFields,
|
|
1312
|
+
cursorValue: z5.number().int().describe("alias of revision, to pass to terminal.capture_since"),
|
|
1313
|
+
columns: z5.number().int(),
|
|
1314
|
+
rows: z5.number().int(),
|
|
1315
|
+
buffer: z5.enum(["normal", "alternate"]),
|
|
1316
|
+
cursor: cursorSchema,
|
|
1317
|
+
modes: modesSchema,
|
|
1318
|
+
scroll: z5.object({
|
|
1319
|
+
offset: z5.number().int(),
|
|
1320
|
+
length: z5.number().int(),
|
|
1321
|
+
retainedFloor: z5.number().int()
|
|
1322
|
+
}),
|
|
1323
|
+
refs: z5.array(refEntrySchema),
|
|
1324
|
+
compact: z5.string(),
|
|
1325
|
+
dumpPath: z5.string().optional(),
|
|
1326
|
+
screenshot: screenshotSchema.optional(),
|
|
1327
|
+
crash: crashSchema.optional()
|
|
1328
|
+
},
|
|
1329
|
+
annotations: { readOnlyHint: true },
|
|
1330
|
+
handler: async (context, args) => {
|
|
1331
|
+
const entry = context.terminals.get(args.terminal);
|
|
1332
|
+
await settleSemantics(entry);
|
|
1333
|
+
const state = capture(context, entry);
|
|
1334
|
+
const screen = entry.harness.screen();
|
|
1335
|
+
const semantic = entry.harness.semanticTree();
|
|
1336
|
+
const full = args.variant === "full";
|
|
1337
|
+
const compact = compactFor(entry, state.rows, {
|
|
1338
|
+
...args.maxNodes === void 0 ? {} : { maxNodes: args.maxNodes },
|
|
1339
|
+
...args.maxRows === void 0 ? {} : { maxRows: args.maxRows },
|
|
1340
|
+
includeText: full ? false : args.includeText !== false
|
|
1341
|
+
});
|
|
1342
|
+
let dumpPath;
|
|
1343
|
+
if (full) {
|
|
1344
|
+
dumpPath = await context.terminals.writeDump(
|
|
1345
|
+
entry,
|
|
1346
|
+
`snapshot-${screen.revision}.json`,
|
|
1347
|
+
`${JSON.stringify(
|
|
1348
|
+
{
|
|
1349
|
+
terminal: entry.id,
|
|
1350
|
+
revision: screen.revision,
|
|
1351
|
+
columns: screen.columns,
|
|
1352
|
+
rows: screen.rows,
|
|
1353
|
+
text: screen.text(),
|
|
1354
|
+
ansi: screen.ansi(),
|
|
1355
|
+
html: screen.html(),
|
|
1356
|
+
semantic
|
|
1357
|
+
},
|
|
1358
|
+
null,
|
|
1359
|
+
2
|
|
1360
|
+
)}
|
|
1361
|
+
`
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
const crash = crashOf(entry);
|
|
1365
|
+
const image = args.screenshot === true ? renderScreenshot(screen, {
|
|
1366
|
+
scale: args.screenshotScale,
|
|
1367
|
+
theme: args.screenshotTheme
|
|
1368
|
+
}) : void 0;
|
|
1369
|
+
const trailer = [
|
|
1370
|
+
...dumpPath === void 0 ? [] : [`full dump: ${dumpPath}`],
|
|
1371
|
+
...crash === void 0 ? [] : [renderCrash(crash)]
|
|
1372
|
+
];
|
|
1373
|
+
return {
|
|
1374
|
+
text: trailer.length === 0 ? compact : `${compact}
|
|
1375
|
+
${trailer.join("\n")}`,
|
|
1376
|
+
...image === void 0 ? {} : { images: [image] },
|
|
1377
|
+
data: {
|
|
1378
|
+
terminal: entry.id,
|
|
1379
|
+
revision: screen.revision,
|
|
1380
|
+
cursorValue: screen.revision,
|
|
1381
|
+
semanticRevision: state.semanticRevision,
|
|
1382
|
+
semanticTree: treeState(semantic !== null),
|
|
1383
|
+
columns: screen.columns,
|
|
1384
|
+
rows: screen.rows,
|
|
1385
|
+
buffer: screen.buffer,
|
|
1386
|
+
cursor: screen.cursor,
|
|
1387
|
+
modes: screen.modes,
|
|
1388
|
+
scroll: {
|
|
1389
|
+
offset: entry.harness.scrollback.position(),
|
|
1390
|
+
length: entry.harness.scrollback.length,
|
|
1391
|
+
retainedFloor: entry.harness.scrollback.retainedFloor
|
|
1392
|
+
},
|
|
1393
|
+
refs: state.refs.map((entry2) => ({ ...entry2, flags: [...entry2.flags] })),
|
|
1394
|
+
compact,
|
|
1395
|
+
...dumpPath === void 0 ? {} : { dumpPath },
|
|
1396
|
+
...image === void 0 ? {} : { screenshot: describeImage(image) },
|
|
1397
|
+
...crash === void 0 ? {} : { crash }
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
var captureSince = defineTool({
|
|
1403
|
+
name: "terminal.capture_since",
|
|
1404
|
+
title: "What changed since a revision",
|
|
1405
|
+
description: "Incremental view: the screen rows that differ and the semantic subtrees that were added, removed or updated since the given cursor. The cursor must be a revision this server handed out earlier (snapshot or capture_since); older cursors fail with history-truncated.",
|
|
1406
|
+
inputSchema: {
|
|
1407
|
+
terminal: terminalId,
|
|
1408
|
+
cursor: z5.number().int().min(0).describe("revision returned by an earlier snapshot"),
|
|
1409
|
+
maxRows: z5.number().int().min(1).max(1e4).optional(),
|
|
1410
|
+
maxSubtrees: z5.number().int().min(1).max(1e3).optional(),
|
|
1411
|
+
maxLogs: z5.number().int().min(0).max(500).optional().describe(`application log entries to return; default ${LOG_LIMITS.maxPerResponse}, 0 to skip`)
|
|
1412
|
+
},
|
|
1413
|
+
outputSchema: {
|
|
1414
|
+
...semanticFields,
|
|
1415
|
+
since: z5.number().int(),
|
|
1416
|
+
changedRows: z5.array(z5.object({ row: z5.number().int(), text: z5.string() })),
|
|
1417
|
+
changedSubtrees: z5.array(
|
|
1418
|
+
z5.object({
|
|
1419
|
+
change: z5.enum(["added", "removed", "updated"]),
|
|
1420
|
+
ref: z5.string(),
|
|
1421
|
+
role: z5.string(),
|
|
1422
|
+
name: z5.string(),
|
|
1423
|
+
compact: z5.string()
|
|
1424
|
+
})
|
|
1425
|
+
),
|
|
1426
|
+
logs: z5.array(logEntrySchema),
|
|
1427
|
+
logsOmitted: z5.number().int().describe("entries dropped between the cursor and the oldest one still buffered"),
|
|
1428
|
+
logCursor: z5.number().int().describe("newest log sequence seen; advances with every capture"),
|
|
1429
|
+
compact: z5.string()
|
|
1430
|
+
},
|
|
1431
|
+
annotations: { readOnlyHint: true },
|
|
1432
|
+
handler: async (context, args) => {
|
|
1433
|
+
const entry = context.terminals.get(args.terminal);
|
|
1434
|
+
await settleSemantics(entry);
|
|
1435
|
+
const before = context.terminals.baseline(entry, args.cursor);
|
|
1436
|
+
const after = context.terminals.record(entry);
|
|
1437
|
+
const rowLimit = args.maxRows ?? 200;
|
|
1438
|
+
const subtreeLimit = args.maxSubtrees ?? 100;
|
|
1439
|
+
const changedRows = diffRows(before.rows, after.rows).slice(0, rowLimit);
|
|
1440
|
+
const changedSubtrees = diffSemantic(before.semantic, after.semantic).slice(0, subtreeLimit);
|
|
1441
|
+
const logs = entry.logs.since(before.logSeq, args.maxLogs ?? LOG_LIMITS.maxPerResponse);
|
|
1442
|
+
const lines = [
|
|
1443
|
+
`Terminal ${entry.id} revision ${after.revision} (since ${args.cursor})`,
|
|
1444
|
+
`semanticTree: ${after.semantic === null ? "unavailable" : "available"}`
|
|
1445
|
+
];
|
|
1446
|
+
lines.push(`changed rows: ${changedRows.length}`);
|
|
1447
|
+
for (const row of changedRows) lines.push(` ${row.row}: ${row.text}`);
|
|
1448
|
+
lines.push(`changed nodes: ${changedSubtrees.length}`);
|
|
1449
|
+
for (const subtree of changedSubtrees) {
|
|
1450
|
+
const marker = subtree.change === "added" ? "+" : subtree.change === "removed" ? "-" : "~";
|
|
1451
|
+
for (const line of subtree.compact.split("\n")) lines.push(` ${marker} ${line}`);
|
|
1452
|
+
}
|
|
1453
|
+
lines.push(renderLogs(logs));
|
|
1454
|
+
return {
|
|
1455
|
+
text: lines.join("\n"),
|
|
1456
|
+
data: {
|
|
1457
|
+
terminal: entry.id,
|
|
1458
|
+
revision: after.revision,
|
|
1459
|
+
semanticRevision: after.semanticRevision,
|
|
1460
|
+
semanticTree: treeState(after.semantic !== null),
|
|
1461
|
+
since: args.cursor,
|
|
1462
|
+
changedRows: changedRows.map((row) => ({ ...row })),
|
|
1463
|
+
changedSubtrees: changedSubtrees.map((subtree) => ({ ...subtree })),
|
|
1464
|
+
logs: logs.entries.map((log) => ({ ...log })),
|
|
1465
|
+
logsOmitted: logs.omitted,
|
|
1466
|
+
logCursor: logs.cursor,
|
|
1467
|
+
compact: lines.join("\n")
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
var query = defineTool({
|
|
1473
|
+
name: "terminal.query",
|
|
1474
|
+
title: "Find matching nodes",
|
|
1475
|
+
description: "Resolves a target to refs without acting on it. Use it to check how many nodes a locator matches before clicking, or to turn a role/name into a ref.",
|
|
1476
|
+
inputSchema: {
|
|
1477
|
+
terminal: terminalId,
|
|
1478
|
+
...targetShape,
|
|
1479
|
+
timeout: timeoutMs.optional(),
|
|
1480
|
+
limit: z5.number().int().min(1).max(100).optional().describe("default 20")
|
|
1481
|
+
},
|
|
1482
|
+
outputSchema: {
|
|
1483
|
+
terminal: z5.string(),
|
|
1484
|
+
revision: z5.number().int(),
|
|
1485
|
+
count: z5.number().int(),
|
|
1486
|
+
matches: z5.array(
|
|
1487
|
+
z5.object({
|
|
1488
|
+
ref: z5.string(),
|
|
1489
|
+
revision: z5.number().int(),
|
|
1490
|
+
semantic: z5.boolean(),
|
|
1491
|
+
role: z5.string().optional(),
|
|
1492
|
+
name: z5.string().optional(),
|
|
1493
|
+
bounds: refEntrySchema.shape.bounds
|
|
1494
|
+
})
|
|
1495
|
+
)
|
|
1496
|
+
},
|
|
1497
|
+
annotations: { readOnlyHint: true },
|
|
1498
|
+
handler: async (context, args) => {
|
|
1499
|
+
const entry = context.terminals.get(args.terminal);
|
|
1500
|
+
await settleSemantics(entry);
|
|
1501
|
+
const locator = locatorFor(entry, args);
|
|
1502
|
+
const limit = args.limit ?? 20;
|
|
1503
|
+
const count = await locator.count();
|
|
1504
|
+
const matches = [];
|
|
1505
|
+
for (let index = 0; index < Math.min(count, limit); index += 1) {
|
|
1506
|
+
const target = await locator.nth(index).resolve(optionalTimeout(args.timeout));
|
|
1507
|
+
matches.push({
|
|
1508
|
+
ref: target.ref,
|
|
1509
|
+
revision: target.revision,
|
|
1510
|
+
semantic: target.semantic,
|
|
1511
|
+
...target.role === void 0 ? {} : { role: target.role },
|
|
1512
|
+
...target.name === void 0 ? {} : { name: target.name },
|
|
1513
|
+
...target.rect === null ? {} : { bounds: target.rect }
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
const text = matches.length === 0 ? `no matches (count ${count})` : matches.map(
|
|
1517
|
+
(match) => `${match.role ?? "generic"} ${JSON.stringify(match.name ?? "")} ref=${match.ref}`
|
|
1518
|
+
).join("\n");
|
|
1519
|
+
return {
|
|
1520
|
+
text,
|
|
1521
|
+
data: { terminal: entry.id, revision: entry.harness.screen().revision, count, matches }
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
function pointerTool(name) {
|
|
1526
|
+
const double = name === "terminal.double_click";
|
|
1527
|
+
return defineTool({
|
|
1528
|
+
name,
|
|
1529
|
+
title: double ? "Double-click a target" : "Click a target",
|
|
1530
|
+
description: `Sends a real ${double ? "double " : ""}mouse report through the pseudo-terminal. Fails with unsupported-action when the program never enabled mouse tracking. Where the platform hides the mode (modes.mouseTracking "unknown", e.g. Windows ConPTY) the click is sent anyway, encoded as SGR.`,
|
|
1531
|
+
inputSchema: {
|
|
1532
|
+
terminal: terminalId,
|
|
1533
|
+
...targetShape,
|
|
1534
|
+
button: buttonSchema.optional(),
|
|
1535
|
+
position: z5.object({ rowOffset: z5.number().int(), columnOffset: z5.number().int() }).optional().describe("offset inside the target rectangle"),
|
|
1536
|
+
timeout: timeoutMs.optional()
|
|
1537
|
+
},
|
|
1538
|
+
outputSchema: { ...receiptFields, ref: z5.string() },
|
|
1539
|
+
handler: async (context, args) => {
|
|
1540
|
+
const entry = context.terminals.get(args.terminal);
|
|
1541
|
+
const locator = locatorFor(entry, args);
|
|
1542
|
+
const target = await locator.resolve(optionalTimeout(args.timeout));
|
|
1543
|
+
const options = {
|
|
1544
|
+
...optionalTimeout(args.timeout),
|
|
1545
|
+
...args.button === void 0 ? {} : { button: args.button },
|
|
1546
|
+
...args.position === void 0 ? {} : { position: args.position }
|
|
1547
|
+
};
|
|
1548
|
+
if (double) await locator.doubleClick(options);
|
|
1549
|
+
else await locator.click(options);
|
|
1550
|
+
return {
|
|
1551
|
+
text: `${double ? "double-clicked" : "clicked"} ref=${target.ref}`,
|
|
1552
|
+
data: { ...receipt(entry), ref: target.ref }
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
var press = defineTool({
|
|
1558
|
+
name: "terminal.press",
|
|
1559
|
+
title: "Press keys",
|
|
1560
|
+
description: 'Sends key chords as real bytes, honouring the modes the program enabled (application cursor keys, keypad). Examples: "Enter", "Escape", "Control+K Control+U". With a target, the node is focused first.',
|
|
1561
|
+
inputSchema: {
|
|
1562
|
+
terminal: terminalId,
|
|
1563
|
+
keys: z5.string().min(1).describe('space-separated chords, e.g. "Control+A Home"'),
|
|
1564
|
+
...targetShape,
|
|
1565
|
+
timeout: timeoutMs.optional()
|
|
1566
|
+
},
|
|
1567
|
+
outputSchema: { ...receiptFields, ref: z5.string().optional() },
|
|
1568
|
+
handler: async (context, args) => {
|
|
1569
|
+
const entry = context.terminals.get(args.terminal);
|
|
1570
|
+
if (hasTarget(args)) {
|
|
1571
|
+
const locator = locatorFor(entry, args);
|
|
1572
|
+
const target = await locator.resolve(optionalTimeout(args.timeout));
|
|
1573
|
+
await locator.press(args.keys, optionalTimeout(args.timeout));
|
|
1574
|
+
return { text: `pressed ${args.keys} on ref=${target.ref}`, data: { ...receipt(entry), ref: target.ref } };
|
|
1575
|
+
}
|
|
1576
|
+
await entry.harness.press(args.keys);
|
|
1577
|
+
return { text: `pressed ${args.keys}`, data: receipt(entry) };
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
var type = defineTool({
|
|
1581
|
+
name: "terminal.type",
|
|
1582
|
+
title: "Type text",
|
|
1583
|
+
description: "Types text as individual keystrokes (not a paste). With a target, the node is focused first.",
|
|
1584
|
+
inputSchema: {
|
|
1585
|
+
terminal: terminalId,
|
|
1586
|
+
text: z5.string(),
|
|
1587
|
+
...targetShapeWithoutText,
|
|
1588
|
+
timeout: timeoutMs.optional()
|
|
1589
|
+
},
|
|
1590
|
+
outputSchema: { ...receiptFields, ref: z5.string().optional() },
|
|
1591
|
+
handler: async (context, args) => {
|
|
1592
|
+
const entry = context.terminals.get(args.terminal);
|
|
1593
|
+
if (hasTarget(args)) {
|
|
1594
|
+
const locator = locatorFor(entry, args);
|
|
1595
|
+
const target = await locator.resolve(optionalTimeout(args.timeout));
|
|
1596
|
+
await locator.type(args.text, optionalTimeout(args.timeout));
|
|
1597
|
+
return { text: `typed into ref=${target.ref}`, data: { ...receipt(entry), ref: target.ref } };
|
|
1598
|
+
}
|
|
1599
|
+
await entry.harness.type(args.text);
|
|
1600
|
+
return { text: `typed ${args.text.length} characters`, data: receipt(entry) };
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
var paste = defineTool({
|
|
1604
|
+
name: "terminal.paste",
|
|
1605
|
+
title: "Paste text",
|
|
1606
|
+
description: "Pastes text, wrapped in bracketed-paste markers when the program enabled that mode. Use it for multi-line input instead of terminal.type.",
|
|
1607
|
+
inputSchema: { terminal: terminalId, text: z5.string() },
|
|
1608
|
+
outputSchema: receiptFields,
|
|
1609
|
+
handler: async (context, args) => {
|
|
1610
|
+
const entry = context.terminals.get(args.terminal);
|
|
1611
|
+
await entry.harness.paste(args.text);
|
|
1612
|
+
return { text: `pasted ${args.text.length} characters`, data: receipt(entry) };
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
var writeRaw = defineTool({
|
|
1616
|
+
name: "terminal.write_raw",
|
|
1617
|
+
title: "Write raw bytes",
|
|
1618
|
+
description: "Writes bytes to the pseudo-terminal verbatim \u2014 no newline, no key encoding. The escape hatch for sequences the key encoder does not model.",
|
|
1619
|
+
inputSchema: {
|
|
1620
|
+
terminal: terminalId,
|
|
1621
|
+
data: z5.string(),
|
|
1622
|
+
encoding: z5.enum(["utf8", "base64"]).optional().describe('default "utf8"')
|
|
1623
|
+
},
|
|
1624
|
+
outputSchema: { ...receiptFields, bytes: z5.number().int() },
|
|
1625
|
+
handler: async (context, args) => {
|
|
1626
|
+
const entry = context.terminals.get(args.terminal);
|
|
1627
|
+
const bytes = args.encoding === "base64" ? new Uint8Array(Buffer.from(args.data, "base64")) : args.data;
|
|
1628
|
+
await entry.harness.write(bytes);
|
|
1629
|
+
const length = typeof bytes === "string" ? Buffer.byteLength(bytes) : bytes.byteLength;
|
|
1630
|
+
return { text: `wrote ${length} bytes`, data: { ...receipt(entry), bytes: length } };
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
var drag = defineTool({
|
|
1634
|
+
name: "terminal.drag",
|
|
1635
|
+
title: "Drag",
|
|
1636
|
+
description: "Drags with real mouse reports: either from one target to another (toTarget), or between two cell positions inside the source target (from/to).",
|
|
1637
|
+
inputSchema: {
|
|
1638
|
+
terminal: terminalId,
|
|
1639
|
+
...targetShape,
|
|
1640
|
+
toTarget: targetObject.optional().describe("drop target; omit when using from/to"),
|
|
1641
|
+
from: cellPosition.optional(),
|
|
1642
|
+
to: cellPosition.optional(),
|
|
1643
|
+
timeout: timeoutMs.optional()
|
|
1644
|
+
},
|
|
1645
|
+
outputSchema: receiptFields,
|
|
1646
|
+
handler: async (context, args) => {
|
|
1647
|
+
const entry = context.terminals.get(args.terminal);
|
|
1648
|
+
const source = locatorFor(entry, args);
|
|
1649
|
+
if (args.toTarget !== void 0) {
|
|
1650
|
+
await source.dragTo(locatorFor(entry, args.toTarget), optionalTimeout(args.timeout));
|
|
1651
|
+
return { text: "dragged to target", data: receipt(entry) };
|
|
1652
|
+
}
|
|
1653
|
+
if (args.from === void 0 || args.to === void 0) {
|
|
1654
|
+
throw usageError("drag needs either toTarget, or both from and to");
|
|
1655
|
+
}
|
|
1656
|
+
await source.drag({ from: args.from, to: args.to });
|
|
1657
|
+
return {
|
|
1658
|
+
text: `dragged (${args.from.row},${args.from.column}) -> (${args.to.row},${args.to.column})`,
|
|
1659
|
+
data: receipt(entry)
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
var wheel = defineTool({
|
|
1664
|
+
name: "terminal.wheel",
|
|
1665
|
+
title: "Scroll with the wheel",
|
|
1666
|
+
description: "Sends wheel reports over a target. Positive deltaY scrolls down.",
|
|
1667
|
+
inputSchema: {
|
|
1668
|
+
terminal: terminalId,
|
|
1669
|
+
...targetShape,
|
|
1670
|
+
deltaY: z5.number().int(),
|
|
1671
|
+
deltaX: z5.number().int().optional()
|
|
1672
|
+
},
|
|
1673
|
+
outputSchema: receiptFields,
|
|
1674
|
+
handler: async (context, args) => {
|
|
1675
|
+
const entry = context.terminals.get(args.terminal);
|
|
1676
|
+
await locatorFor(entry, args).wheel({
|
|
1677
|
+
deltaY: args.deltaY,
|
|
1678
|
+
...args.deltaX === void 0 ? {} : { deltaX: args.deltaX }
|
|
1679
|
+
});
|
|
1680
|
+
return { text: `wheel deltaY=${args.deltaY}`, data: receipt(entry) };
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
var resize = defineTool({
|
|
1684
|
+
name: "terminal.resize",
|
|
1685
|
+
title: "Resize the terminal",
|
|
1686
|
+
description: "Resizes the pseudo-terminal; the child sees a real SIGWINCH.",
|
|
1687
|
+
inputSchema: {
|
|
1688
|
+
terminal: terminalId,
|
|
1689
|
+
columns: z5.number().int().min(1).max(1e3),
|
|
1690
|
+
rows: z5.number().int().min(1).max(1e3)
|
|
1691
|
+
},
|
|
1692
|
+
outputSchema: { ...receiptFields, columns: z5.number().int(), rows: z5.number().int() },
|
|
1693
|
+
handler: async (context, args) => {
|
|
1694
|
+
const entry = context.terminals.get(args.terminal);
|
|
1695
|
+
await entry.harness.resize({ columns: args.columns, rows: args.rows });
|
|
1696
|
+
return {
|
|
1697
|
+
text: `resized to ${args.columns}x${args.rows}`,
|
|
1698
|
+
data: { ...receipt(entry), columns: args.columns, rows: args.rows }
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
var signal = defineTool({
|
|
1703
|
+
name: "terminal.signal",
|
|
1704
|
+
title: "Send a signal",
|
|
1705
|
+
description: "Sends INT, TERM, KILL or HUP to the child. Destructive by design: terminal.close cleans up without signalling.",
|
|
1706
|
+
inputSchema: { terminal: terminalId, signal: signalSchema },
|
|
1707
|
+
outputSchema: receiptFields,
|
|
1708
|
+
annotations: { destructiveHint: true },
|
|
1709
|
+
handler: async (context, args) => {
|
|
1710
|
+
const entry = context.terminals.get(args.terminal);
|
|
1711
|
+
await entry.harness.signal(args.signal);
|
|
1712
|
+
return { text: `sent SIG${args.signal}`, data: receipt(entry) };
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
var scrollback = defineTool({
|
|
1716
|
+
name: "terminal.scrollback",
|
|
1717
|
+
title: "Read or move the scrollback",
|
|
1718
|
+
description: "Emulator-side history: read a line range, search it, or move the viewport. The child sees nothing \u2014 no input is sent.",
|
|
1719
|
+
inputSchema: {
|
|
1720
|
+
terminal: terminalId,
|
|
1721
|
+
move: z5.number().int().optional().describe("lines to move the viewport; negative scrolls up"),
|
|
1722
|
+
from: z5.number().int().min(0).optional(),
|
|
1723
|
+
to: z5.number().int().min(0).optional(),
|
|
1724
|
+
search: z5.string().optional().describe('literal text, or "/pattern/flags"'),
|
|
1725
|
+
limit: z5.number().int().min(1).max(1e3).optional().describe("max search hits (default 50)")
|
|
1726
|
+
},
|
|
1727
|
+
outputSchema: {
|
|
1728
|
+
terminal: z5.string(),
|
|
1729
|
+
length: z5.number().int(),
|
|
1730
|
+
retainedFloor: z5.number().int(),
|
|
1731
|
+
position: z5.number().int(),
|
|
1732
|
+
text: z5.string().optional(),
|
|
1733
|
+
matches: z5.array(z5.object({ line: z5.number().int(), match: z5.string() })).optional()
|
|
1734
|
+
},
|
|
1735
|
+
annotations: { readOnlyHint: true },
|
|
1736
|
+
handler: async (context, args) => {
|
|
1737
|
+
const entry = context.terminals.get(args.terminal);
|
|
1738
|
+
const api = entry.harness.scrollback;
|
|
1739
|
+
if (args.move !== void 0) api.move({ lines: args.move });
|
|
1740
|
+
const wantsText = args.from !== void 0 || args.to !== void 0 || args.search === void 0;
|
|
1741
|
+
const text = wantsText ? api.text({
|
|
1742
|
+
...args.from === void 0 ? {} : { from: args.from },
|
|
1743
|
+
...args.to === void 0 ? {} : { to: args.to }
|
|
1744
|
+
}) : void 0;
|
|
1745
|
+
const matches = args.search === void 0 ? void 0 : api.search(textOrRegExp(args.search)).slice(0, args.limit ?? 50);
|
|
1746
|
+
const lines = [
|
|
1747
|
+
`scrollback length ${api.length} floor ${api.retainedFloor} position ${api.position()}`
|
|
1748
|
+
];
|
|
1749
|
+
if (matches !== void 0) {
|
|
1750
|
+
lines.push(...matches.map((match) => ` ${match.line}: ${match.match}`));
|
|
1751
|
+
}
|
|
1752
|
+
if (text !== void 0) lines.push(text);
|
|
1753
|
+
return {
|
|
1754
|
+
text: lines.join("\n"),
|
|
1755
|
+
data: {
|
|
1756
|
+
terminal: entry.id,
|
|
1757
|
+
length: api.length,
|
|
1758
|
+
retainedFloor: api.retainedFloor,
|
|
1759
|
+
position: api.position(),
|
|
1760
|
+
...text === void 0 ? {} : { text },
|
|
1761
|
+
...matches === void 0 ? {} : { matches: matches.map((match) => ({ ...match })) }
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
var selectCells = defineTool({
|
|
1767
|
+
name: "terminal.select_cells",
|
|
1768
|
+
title: "Select a cell range",
|
|
1769
|
+
description: "Selects a rectangle in the emulator (like a mouse selection). No input is sent.",
|
|
1770
|
+
inputSchema: { terminal: terminalId, start: cellPosition, end: cellPosition },
|
|
1771
|
+
outputSchema: receiptFields,
|
|
1772
|
+
handler: async (context, args) => {
|
|
1773
|
+
const entry = context.terminals.get(args.terminal);
|
|
1774
|
+
entry.harness.selection.selectCells({ start: args.start, end: args.end });
|
|
1775
|
+
return {
|
|
1776
|
+
text: `selected (${args.start.row},${args.start.column})-(${args.end.row},${args.end.column})`,
|
|
1777
|
+
data: receipt(entry)
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
var copySelection = defineTool({
|
|
1782
|
+
name: "terminal.copy_selection",
|
|
1783
|
+
title: "Copy the selection",
|
|
1784
|
+
description: "Returns the text of the current selection and optionally clears it.",
|
|
1785
|
+
inputSchema: { terminal: terminalId, clear: z5.boolean().optional() },
|
|
1786
|
+
outputSchema: { terminal: z5.string(), text: z5.string() },
|
|
1787
|
+
annotations: { readOnlyHint: true },
|
|
1788
|
+
handler: async (context, args) => {
|
|
1789
|
+
const entry = context.terminals.get(args.terminal);
|
|
1790
|
+
const text = entry.harness.selection.copy();
|
|
1791
|
+
if (args.clear === true) entry.harness.selection.clear();
|
|
1792
|
+
return { text, data: { terminal: entry.id, text } };
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
var waitFor = defineTool({
|
|
1796
|
+
name: "terminal.wait_for",
|
|
1797
|
+
title: "Wait for a condition",
|
|
1798
|
+
description: 'Revision-driven waits \u2014 never a sleep. "text"/"title" wait for content, "visible"/"hidden"/"attached" wait on a target, "stable" waits for renders to settle, "idle" for output to stop, "render" for a render after a given revision, "exit" for the child to exit.',
|
|
1799
|
+
inputSchema: {
|
|
1800
|
+
terminal: terminalId,
|
|
1801
|
+
wait: z5.enum(["text", "title", "visible", "hidden", "attached", "stable", "idle", "render", "exit"]),
|
|
1802
|
+
text: z5.string().optional().describe('for wait="text"; "/pattern/flags" is a regular expression'),
|
|
1803
|
+
title: z5.string().optional().describe('for wait="title"'),
|
|
1804
|
+
...targetShapeWithoutText,
|
|
1805
|
+
frames: z5.number().int().min(1).optional().describe('for wait="stable"'),
|
|
1806
|
+
after: z5.number().int().min(0).optional().describe('for wait="render": the revision to beat'),
|
|
1807
|
+
timeout: timeoutMs.optional()
|
|
1808
|
+
},
|
|
1809
|
+
outputSchema: {
|
|
1810
|
+
...receiptFields,
|
|
1811
|
+
wait: z5.string(),
|
|
1812
|
+
exit: exitSchema.optional()
|
|
1813
|
+
},
|
|
1814
|
+
handler: async (context, args) => {
|
|
1815
|
+
const entry = context.terminals.get(args.terminal);
|
|
1816
|
+
const timeout = optionalTimeout(args.timeout);
|
|
1817
|
+
switch (args.wait) {
|
|
1818
|
+
case "text": {
|
|
1819
|
+
if (args.text === void 0) throw usageError('wait="text" needs text');
|
|
1820
|
+
await entry.harness.waitForText(textOrRegExp(args.text), timeout);
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
case "title": {
|
|
1824
|
+
if (args.title === void 0) throw usageError('wait="title" needs title');
|
|
1825
|
+
await entry.harness.waitForTitle(textOrRegExp(args.title), timeout);
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
case "visible":
|
|
1829
|
+
case "hidden":
|
|
1830
|
+
case "attached": {
|
|
1831
|
+
await locatorFor(entry, args).waitFor({ state: args.wait, ...timeout });
|
|
1832
|
+
break;
|
|
1833
|
+
}
|
|
1834
|
+
case "stable": {
|
|
1835
|
+
await entry.harness.waitForStable({
|
|
1836
|
+
...args.frames === void 0 ? {} : { frames: args.frames },
|
|
1837
|
+
...timeout
|
|
1838
|
+
});
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
case "idle": {
|
|
1842
|
+
await entry.harness.waitForIdle(timeout);
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
case "render": {
|
|
1846
|
+
if (args.after === void 0) throw usageError('wait="render" needs after');
|
|
1847
|
+
await entry.harness.waitForRender({ after: args.after, ...timeout });
|
|
1848
|
+
break;
|
|
1849
|
+
}
|
|
1850
|
+
case "exit": {
|
|
1851
|
+
const status = await entry.harness.waitForExit(timeout);
|
|
1852
|
+
return {
|
|
1853
|
+
text: `exited code=${String(status.code)} signal=${String(status.signal)}`,
|
|
1854
|
+
data: { ...receipt(entry), wait: args.wait, exit: status }
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
return { text: `wait ${args.wait} satisfied`, data: { ...receipt(entry), wait: args.wait } };
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
var close = defineTool({
|
|
1862
|
+
name: "terminal.close",
|
|
1863
|
+
title: "Close a terminal",
|
|
1864
|
+
description: "Bounded physical cleanup: hangs up the pseudo-terminal and forgets the handle. Send signals explicitly with terminal.signal if the child must be killed first.",
|
|
1865
|
+
inputSchema: { terminal: terminalId },
|
|
1866
|
+
outputSchema: { ok: z5.literal(true), terminal: z5.string(), exit: exitSchema.nullable() },
|
|
1867
|
+
annotations: { idempotentHint: true },
|
|
1868
|
+
handler: async (context, args) => {
|
|
1869
|
+
const entry = await context.terminals.close(args.terminal);
|
|
1870
|
+
return {
|
|
1871
|
+
text: `closed ${entry.id}`,
|
|
1872
|
+
data: { ok: true, terminal: entry.id, exit: entry.exit }
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
var TERMINAL_TOOLS = Object.freeze([
|
|
1877
|
+
launch,
|
|
1878
|
+
capabilities,
|
|
1879
|
+
snapshot,
|
|
1880
|
+
captureSince,
|
|
1881
|
+
query,
|
|
1882
|
+
pointerTool("terminal.click"),
|
|
1883
|
+
pointerTool("terminal.double_click"),
|
|
1884
|
+
press,
|
|
1885
|
+
type,
|
|
1886
|
+
paste,
|
|
1887
|
+
writeRaw,
|
|
1888
|
+
drag,
|
|
1889
|
+
wheel,
|
|
1890
|
+
resize,
|
|
1891
|
+
signal,
|
|
1892
|
+
scrollback,
|
|
1893
|
+
selectCells,
|
|
1894
|
+
copySelection,
|
|
1895
|
+
waitFor,
|
|
1896
|
+
close
|
|
1897
|
+
]);
|
|
1898
|
+
|
|
1899
|
+
// src/trace-tools.ts
|
|
1900
|
+
import { z as z6 } from "zod";
|
|
1901
|
+
import { frameFromAnsi } from "@termwright/trace";
|
|
1902
|
+
var traceId = z6.string().min(1).describe('trace handle returned by trace.open, e.g. "tr1"');
|
|
1903
|
+
var screenshotShape = {
|
|
1904
|
+
screenshot: z6.boolean().optional().describe("also attach a PNG of the reconstructed frame, rendered without a browser"),
|
|
1905
|
+
screenshotScale: z6.number().min(0.1).max(3).optional().describe("pixel density of the PNG; default 1, 2 is retina-sharp"),
|
|
1906
|
+
screenshotTheme: z6.enum(["dark", "light"]).optional().describe("PNG background; default dark")
|
|
1907
|
+
};
|
|
1908
|
+
var metaSchema = z6.object({
|
|
1909
|
+
sessionId: z6.string(),
|
|
1910
|
+
command: z6.array(z6.string()),
|
|
1911
|
+
columns: z6.number().int(),
|
|
1912
|
+
rows: z6.number().int(),
|
|
1913
|
+
startedAt: z6.string(),
|
|
1914
|
+
platform: z6.string(),
|
|
1915
|
+
semanticTree: z6.boolean(),
|
|
1916
|
+
durationMs: z6.number().optional(),
|
|
1917
|
+
truncated: z6.boolean().optional(),
|
|
1918
|
+
exit: z6.object({ code: z6.number().int().nullable(), signal: z6.string().nullable() }).optional()
|
|
1919
|
+
});
|
|
1920
|
+
function crashOfMeta(meta) {
|
|
1921
|
+
const crash = meta.crash;
|
|
1922
|
+
if (crash === void 0) return void 0;
|
|
1923
|
+
const parsed = crashSchema.safeParse({
|
|
1924
|
+
exit: crash.exit,
|
|
1925
|
+
timeMs: crash.castOffset,
|
|
1926
|
+
screenTail: crash.screenTail,
|
|
1927
|
+
screenTailTruncated: false,
|
|
1928
|
+
lastSemanticRevision: crash.lastSemanticRevision ?? null,
|
|
1929
|
+
recentInputs: crash.recentInputs ?? [],
|
|
1930
|
+
diagnostics: crash.diagnosticsTail ?? []
|
|
1931
|
+
});
|
|
1932
|
+
return parsed.success ? parsed.data : void 0;
|
|
1933
|
+
}
|
|
1934
|
+
function projectMeta(meta) {
|
|
1935
|
+
return {
|
|
1936
|
+
sessionId: meta.sessionId,
|
|
1937
|
+
command: [...meta.command],
|
|
1938
|
+
columns: meta.columns,
|
|
1939
|
+
rows: meta.rows,
|
|
1940
|
+
startedAt: meta.startedAt,
|
|
1941
|
+
platform: meta.platform,
|
|
1942
|
+
semanticTree: meta.semanticTree,
|
|
1943
|
+
...meta.durationMs === void 0 ? {} : { durationMs: meta.durationMs },
|
|
1944
|
+
...meta.truncated === void 0 ? {} : { truncated: meta.truncated },
|
|
1945
|
+
...meta.exit === void 0 ? {} : { exit: { code: meta.exit.code, signal: meta.exit.signal } }
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
var stepSchema = z6.object({
|
|
1949
|
+
index: z6.number().int(),
|
|
1950
|
+
stepId: z6.string(),
|
|
1951
|
+
title: z6.string(),
|
|
1952
|
+
parentStepId: z6.string().optional(),
|
|
1953
|
+
status: z6.enum(["passed", "failed", "skipped"]).nullable(),
|
|
1954
|
+
error: z6.string().optional(),
|
|
1955
|
+
castOffset: z6.number(),
|
|
1956
|
+
castEndOffset: z6.number().nullable()
|
|
1957
|
+
});
|
|
1958
|
+
function projectStep(step, index) {
|
|
1959
|
+
return {
|
|
1960
|
+
index,
|
|
1961
|
+
stepId: step.stepId,
|
|
1962
|
+
title: step.title,
|
|
1963
|
+
...step.parentStepId === void 0 ? {} : { parentStepId: step.parentStepId },
|
|
1964
|
+
status: step.status,
|
|
1965
|
+
...step.error === void 0 ? {} : { error: step.error },
|
|
1966
|
+
castOffset: step.castOffset,
|
|
1967
|
+
castEndOffset: step.castEndOffset
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
async function markersOf(reader) {
|
|
1971
|
+
const markers = [];
|
|
1972
|
+
for await (const event of reader.castEvents()) {
|
|
1973
|
+
if (event.code === "m") markers.push({ timeMs: event.timeMs, label: event.data });
|
|
1974
|
+
}
|
|
1975
|
+
return markers;
|
|
1976
|
+
}
|
|
1977
|
+
function fromTraceLog(entry, index) {
|
|
1978
|
+
return {
|
|
1979
|
+
seq: entry.seq ?? index + 1,
|
|
1980
|
+
timeMs: entry.castOffset,
|
|
1981
|
+
source: entry.source,
|
|
1982
|
+
...entry.label === void 0 ? {} : { label: entry.label },
|
|
1983
|
+
...entry.level === void 0 ? {} : { level: entry.level },
|
|
1984
|
+
message: entry.message,
|
|
1985
|
+
...entry.attrs === void 0 ? {} : { attrs: { ...entry.attrs } }
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
async function logsBetween(reader, fromMs, toMs, limit) {
|
|
1989
|
+
if (limit <= 0) return { entries: [], omitted: 0 };
|
|
1990
|
+
const window = [];
|
|
1991
|
+
let index = 0;
|
|
1992
|
+
let seen = 0;
|
|
1993
|
+
for await (const entry of reader.logs()) {
|
|
1994
|
+
if (entry.castOffset > fromMs && entry.castOffset <= toMs) {
|
|
1995
|
+
seen += 1;
|
|
1996
|
+
window.push(fromTraceLog(entry, index));
|
|
1997
|
+
if (window.length > limit) window.shift();
|
|
1998
|
+
}
|
|
1999
|
+
index += 1;
|
|
2000
|
+
}
|
|
2001
|
+
return { entries: window, omitted: seen - window.length };
|
|
2002
|
+
}
|
|
2003
|
+
async function frameAt(trace, timeMs, logWindow = 20) {
|
|
2004
|
+
const state = await trace.reader.stateAt(timeMs, { logWindow });
|
|
2005
|
+
const grid = await frameFromAnsi(state.castPrefix, {
|
|
2006
|
+
columns: state.columns,
|
|
2007
|
+
rows: state.rows,
|
|
2008
|
+
timeMs: state.timeMs,
|
|
2009
|
+
semanticRevision: state.nearestSemanticRevision
|
|
2010
|
+
});
|
|
2011
|
+
return {
|
|
2012
|
+
grid,
|
|
2013
|
+
timeMs: state.timeMs,
|
|
2014
|
+
columns: state.columns,
|
|
2015
|
+
rows: state.rows,
|
|
2016
|
+
lines: grid.text().split("\n"),
|
|
2017
|
+
semantic: state.nearestSemantic?.snapshot ?? null,
|
|
2018
|
+
semanticRevision: state.nearestSemanticRevision,
|
|
2019
|
+
step: state.step,
|
|
2020
|
+
logs: state.logs.map((entry, index) => fromTraceLog(entry, index))
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
async function resolveTime(trace, args) {
|
|
2024
|
+
const given = [args.timeMs, args.stepIndex, args.marker].filter((value) => value !== void 0);
|
|
2025
|
+
if (given.length !== 1) {
|
|
2026
|
+
throw usageError(
|
|
2027
|
+
"give exactly one of timeMs, stepIndex or marker",
|
|
2028
|
+
"trace.overview lists step indexes and marker labels"
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
if (args.timeMs !== void 0) return args.timeMs;
|
|
2032
|
+
if (args.stepIndex !== void 0) {
|
|
2033
|
+
const steps = await trace.reader.steps();
|
|
2034
|
+
const step = steps[args.stepIndex];
|
|
2035
|
+
if (step === void 0) {
|
|
2036
|
+
throw usageError(
|
|
2037
|
+
`no step ${args.stepIndex}; the trace has ${steps.length}`,
|
|
2038
|
+
"call trace.overview for the step list"
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
return step.castOffset;
|
|
2042
|
+
}
|
|
2043
|
+
const label = args.marker ?? "";
|
|
2044
|
+
const markers = await markersOf(trace.reader);
|
|
2045
|
+
const marker = markers.find((candidate) => candidate.label === label);
|
|
2046
|
+
if (marker === void 0) {
|
|
2047
|
+
throw usageError(
|
|
2048
|
+
`no marker ${JSON.stringify(label)}`,
|
|
2049
|
+
markers.length === 0 ? "this recording has no markers; use stepIndex or timeMs" : `markers: ${markers.map((candidate) => JSON.stringify(candidate.label)).join(", ")}`
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
return marker.timeMs;
|
|
2053
|
+
}
|
|
2054
|
+
function renderFrame(trace, frame2, maxRows) {
|
|
2055
|
+
return formatCompactSnapshot({
|
|
2056
|
+
terminal: trace.id,
|
|
2057
|
+
columns: frame2.columns,
|
|
2058
|
+
rows: frame2.rows,
|
|
2059
|
+
revision: frame2.semanticRevision ?? 0,
|
|
2060
|
+
semantic: frame2.semantic,
|
|
2061
|
+
text: frame2.lines,
|
|
2062
|
+
maxRows: maxRows ?? TRACE_LIMITS.maxFrameRows
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
var open = defineTool({
|
|
2066
|
+
name: "trace.open",
|
|
2067
|
+
title: "Open a trace archive",
|
|
2068
|
+
description: "Validates a .twtrace directory or zip and returns a handle plus its metadata: the recorded command, viewport, duration, exit status and whether the session published a semantic tree. Start every replay investigation here.",
|
|
2069
|
+
inputSchema: {
|
|
2070
|
+
path: z6.string().min(1).describe("path to a .twtrace directory or zip")
|
|
2071
|
+
},
|
|
2072
|
+
outputSchema: {
|
|
2073
|
+
traceId: z6.string(),
|
|
2074
|
+
path: z6.string(),
|
|
2075
|
+
meta: metaSchema,
|
|
2076
|
+
steps: z6.number().int(),
|
|
2077
|
+
evicted: z6.string().nullable().describe("handle closed to make room, if the open-trace ceiling was reached")
|
|
2078
|
+
},
|
|
2079
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2080
|
+
handler: async (context, args) => {
|
|
2081
|
+
const { trace, evicted } = await context.traces.open(args.path);
|
|
2082
|
+
const meta = trace.reader.meta;
|
|
2083
|
+
const steps = await trace.reader.steps();
|
|
2084
|
+
const exit = meta.exit;
|
|
2085
|
+
return {
|
|
2086
|
+
text: [
|
|
2087
|
+
`Trace ${trace.id} ${meta.command.join(" ")} ${meta.columns}x${meta.rows}`,
|
|
2088
|
+
`recorded: ${meta.startedAt} on ${meta.platform}`,
|
|
2089
|
+
`semanticTree: ${meta.semanticTree ? "available" : "unavailable"}`,
|
|
2090
|
+
`steps: ${steps.length}`,
|
|
2091
|
+
exit === void 0 ? "exit: not recorded" : `exit: code=${String(exit.code)} signal=${String(exit.signal)}`,
|
|
2092
|
+
...meta.truncated === true ? ["warning: recording was truncated at a size limit"] : [],
|
|
2093
|
+
...evicted === null ? [] : [`note: closed ${evicted} to stay within the open-trace ceiling`]
|
|
2094
|
+
].join("\n"),
|
|
2095
|
+
data: {
|
|
2096
|
+
traceId: trace.id,
|
|
2097
|
+
path: trace.path,
|
|
2098
|
+
meta: projectMeta(meta),
|
|
2099
|
+
steps: steps.length,
|
|
2100
|
+
evicted
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
var overview = defineTool({
|
|
2106
|
+
name: "trace.overview",
|
|
2107
|
+
title: "Summarise a trace",
|
|
2108
|
+
description: "The shape of a recording: every step with its status and timing, the cast markers, the exit status, and which step failed. Use it to pick the moment worth reconstructing before calling trace.frame_at.",
|
|
2109
|
+
inputSchema: { traceId },
|
|
2110
|
+
outputSchema: {
|
|
2111
|
+
traceId: z6.string(),
|
|
2112
|
+
durationMs: z6.number().nullable(),
|
|
2113
|
+
semanticTree: semanticTreeState,
|
|
2114
|
+
exit: z6.object({ code: z6.number().int().nullable(), signal: z6.string().nullable() }).nullable(),
|
|
2115
|
+
truncated: z6.boolean(),
|
|
2116
|
+
steps: z6.array(stepSchema),
|
|
2117
|
+
failedSteps: z6.array(stepSchema),
|
|
2118
|
+
markers: z6.array(z6.object({ timeMs: z6.number(), label: z6.string() })),
|
|
2119
|
+
crash: crashSchema.optional()
|
|
2120
|
+
},
|
|
2121
|
+
annotations: { readOnlyHint: true },
|
|
2122
|
+
handler: async (context, args) => {
|
|
2123
|
+
const trace = context.traces.get(args.traceId);
|
|
2124
|
+
const meta = trace.reader.meta;
|
|
2125
|
+
const steps = (await trace.reader.steps()).map(projectStep);
|
|
2126
|
+
const failedSteps = steps.filter((step) => step.status === "failed");
|
|
2127
|
+
const markers = [...await markersOf(trace.reader)];
|
|
2128
|
+
const lines = [
|
|
2129
|
+
`Trace ${trace.id} ${meta.command.join(" ")} ${meta.columns}x${meta.rows}`,
|
|
2130
|
+
`semanticTree: ${meta.semanticTree ? "available" : "unavailable"}`,
|
|
2131
|
+
`duration: ${meta.durationMs === void 0 ? "unknown" : `${meta.durationMs} ms`}`,
|
|
2132
|
+
`exit: ${meta.exit === void 0 ? "not recorded" : `code=${String(meta.exit.code)} signal=${String(meta.exit.signal)}`}`,
|
|
2133
|
+
`steps: ${steps.length}${failedSteps.length === 0 ? "" : ` (${failedSteps.length} failed)`}`
|
|
2134
|
+
];
|
|
2135
|
+
for (const step of steps) {
|
|
2136
|
+
const status = step.status ?? "unfinished";
|
|
2137
|
+
const window = `${step.castOffset}..${step.castEndOffset === null ? "?" : step.castEndOffset}ms`;
|
|
2138
|
+
lines.push(
|
|
2139
|
+
` [${step.index}] ${status} ${JSON.stringify(step.title)} ${window}` + (step.error === void 0 ? "" : `
|
|
2140
|
+
${step.error}`)
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
if (markers.length > 0) {
|
|
2144
|
+
lines.push(`markers: ${markers.map((marker) => `${marker.timeMs}ms ${marker.label}`).join(", ")}`);
|
|
2145
|
+
}
|
|
2146
|
+
const crash = crashOfMeta(meta);
|
|
2147
|
+
if (crash !== void 0) lines.push(renderCrash(crash));
|
|
2148
|
+
return {
|
|
2149
|
+
text: lines.join("\n"),
|
|
2150
|
+
data: {
|
|
2151
|
+
traceId: trace.id,
|
|
2152
|
+
durationMs: meta.durationMs ?? null,
|
|
2153
|
+
semanticTree: meta.semanticTree ? "available" : "unavailable",
|
|
2154
|
+
exit: meta.exit === void 0 ? null : { code: meta.exit.code, signal: meta.exit.signal },
|
|
2155
|
+
truncated: meta.truncated === true,
|
|
2156
|
+
steps,
|
|
2157
|
+
failedSteps,
|
|
2158
|
+
markers,
|
|
2159
|
+
...crash === void 0 ? {} : { crash }
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
var frame = defineTool({
|
|
2165
|
+
name: "trace.frame_at",
|
|
2166
|
+
title: "Reconstruct one moment",
|
|
2167
|
+
description: "Rebuilds the screen at a moment \u2014 named by timeMs, stepIndex or marker \u2014 by replaying the recording into a headless emulator, and pairs it with the semantic tree of the nearest revision at or before that moment. Reads exactly like a live terminal.snapshot.",
|
|
2168
|
+
inputSchema: {
|
|
2169
|
+
traceId,
|
|
2170
|
+
timeMs: z6.number().min(0).optional().describe("cast-timeline offset in milliseconds"),
|
|
2171
|
+
stepIndex: z6.number().int().min(0).optional().describe("step index from trace.overview"),
|
|
2172
|
+
marker: z6.string().optional().describe("cast marker label from trace.overview"),
|
|
2173
|
+
maxRows: z6.number().int().min(1).max(1e4).optional(),
|
|
2174
|
+
maxLogs: z6.number().int().min(0).max(500).optional().describe("preceding application log entries to include; default 20, 0 to skip"),
|
|
2175
|
+
...screenshotShape
|
|
2176
|
+
},
|
|
2177
|
+
outputSchema: {
|
|
2178
|
+
traceId: z6.string(),
|
|
2179
|
+
timeMs: z6.number(),
|
|
2180
|
+
columns: z6.number().int(),
|
|
2181
|
+
rows: z6.number().int(),
|
|
2182
|
+
semanticRevision: z6.number().int().nullable(),
|
|
2183
|
+
semanticTree: semanticTreeState,
|
|
2184
|
+
step: stepSchema.partial().nullable(),
|
|
2185
|
+
refs: z6.array(refEntrySchema),
|
|
2186
|
+
logs: z6.array(logEntrySchema),
|
|
2187
|
+
compact: z6.string(),
|
|
2188
|
+
screenshot: screenshotSchema.optional()
|
|
2189
|
+
},
|
|
2190
|
+
annotations: { readOnlyHint: true },
|
|
2191
|
+
handler: async (context, args) => {
|
|
2192
|
+
const trace = context.traces.get(args.traceId);
|
|
2193
|
+
const at = await resolveTime(trace, args);
|
|
2194
|
+
const reconstructed = await frameAt(trace, at, args.maxLogs ?? 20);
|
|
2195
|
+
const compact = renderFrame(trace, reconstructed, args.maxRows);
|
|
2196
|
+
const logs = reconstructed.logs.length === 0 ? "" : `
|
|
2197
|
+
${renderLogs({ entries: reconstructed.logs, omitted: 0, cursor: 0 })}`;
|
|
2198
|
+
const step = reconstructed.step;
|
|
2199
|
+
const image = args.screenshot === true ? renderScreenshot(reconstructed.grid, {
|
|
2200
|
+
scale: args.screenshotScale,
|
|
2201
|
+
theme: args.screenshotTheme
|
|
2202
|
+
}) : void 0;
|
|
2203
|
+
return {
|
|
2204
|
+
text: `${compact}${logs}`,
|
|
2205
|
+
...image === void 0 ? {} : { images: [image] },
|
|
2206
|
+
data: {
|
|
2207
|
+
traceId: trace.id,
|
|
2208
|
+
timeMs: reconstructed.timeMs,
|
|
2209
|
+
columns: reconstructed.columns,
|
|
2210
|
+
rows: reconstructed.rows,
|
|
2211
|
+
semanticRevision: reconstructed.semanticRevision,
|
|
2212
|
+
semanticTree: reconstructed.semantic === null ? "unavailable" : "available",
|
|
2213
|
+
step: step === null ? null : projectStep(step, 0),
|
|
2214
|
+
refs: reconstructed.semantic === null ? [] : refEntries(reconstructed.semantic).map((entry) => ({ ...entry, flags: [...entry.flags] })),
|
|
2215
|
+
logs: reconstructed.logs.map((entry) => ({ ...entry })),
|
|
2216
|
+
compact,
|
|
2217
|
+
...image === void 0 ? {} : { screenshot: describeImage(image) }
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
var diff = defineTool({
|
|
2223
|
+
name: "trace.diff",
|
|
2224
|
+
title: "What changed between two moments",
|
|
2225
|
+
description: "Reconstructs two moments of a recording and reports what moved: changed screen rows and changed semantic subtrees, in the same shape as terminal.capture_since on a live session.",
|
|
2226
|
+
inputSchema: {
|
|
2227
|
+
traceId,
|
|
2228
|
+
fromMs: z6.number().min(0),
|
|
2229
|
+
toMs: z6.number().min(0),
|
|
2230
|
+
maxRows: z6.number().int().min(1).max(1e4).optional(),
|
|
2231
|
+
maxSubtrees: z6.number().int().min(1).max(1e3).optional(),
|
|
2232
|
+
maxLogs: z6.number().int().min(0).max(500).optional().describe(`application log entries between the two moments; default ${LOG_LIMITS.maxPerResponse}, 0 to skip`)
|
|
2233
|
+
},
|
|
2234
|
+
outputSchema: {
|
|
2235
|
+
traceId: z6.string(),
|
|
2236
|
+
fromMs: z6.number(),
|
|
2237
|
+
toMs: z6.number(),
|
|
2238
|
+
semanticTree: semanticTreeState,
|
|
2239
|
+
changedRows: z6.array(z6.object({ row: z6.number().int(), text: z6.string() })),
|
|
2240
|
+
changedSubtrees: z6.array(
|
|
2241
|
+
z6.object({
|
|
2242
|
+
change: z6.enum(["added", "removed", "updated"]),
|
|
2243
|
+
ref: z6.string(),
|
|
2244
|
+
role: z6.string(),
|
|
2245
|
+
name: z6.string(),
|
|
2246
|
+
compact: z6.string()
|
|
2247
|
+
})
|
|
2248
|
+
),
|
|
2249
|
+
logs: z6.array(logEntrySchema),
|
|
2250
|
+
logsOmitted: z6.number().int(),
|
|
2251
|
+
compact: z6.string()
|
|
2252
|
+
},
|
|
2253
|
+
annotations: { readOnlyHint: true },
|
|
2254
|
+
handler: async (context, args) => {
|
|
2255
|
+
if (args.toMs < args.fromMs) {
|
|
2256
|
+
throw usageError("toMs must not precede fromMs", "swap the two, or read trace.overview for the timeline");
|
|
2257
|
+
}
|
|
2258
|
+
const trace = context.traces.get(args.traceId);
|
|
2259
|
+
const before = await frameAt(trace, args.fromMs, 0);
|
|
2260
|
+
const after = await frameAt(trace, args.toMs, 0);
|
|
2261
|
+
const logs = await logsBetween(
|
|
2262
|
+
trace.reader,
|
|
2263
|
+
before.timeMs,
|
|
2264
|
+
after.timeMs,
|
|
2265
|
+
args.maxLogs ?? LOG_LIMITS.maxPerResponse
|
|
2266
|
+
);
|
|
2267
|
+
const changedRows = diffRows(before.lines, after.lines).slice(
|
|
2268
|
+
0,
|
|
2269
|
+
args.maxRows ?? TRACE_LIMITS.maxFrameRows
|
|
2270
|
+
);
|
|
2271
|
+
const changedSubtrees = diffSemantic(before.semantic, after.semantic).slice(
|
|
2272
|
+
0,
|
|
2273
|
+
args.maxSubtrees ?? 100
|
|
2274
|
+
);
|
|
2275
|
+
const lines = [
|
|
2276
|
+
`Trace ${trace.id} ${before.timeMs}ms -> ${after.timeMs}ms`,
|
|
2277
|
+
`semanticTree: ${after.semantic === null ? "unavailable" : "available"}`,
|
|
2278
|
+
`changed rows: ${changedRows.length}`,
|
|
2279
|
+
...changedRows.map((row) => ` ${row.row}: ${row.text}`),
|
|
2280
|
+
`changed nodes: ${changedSubtrees.length}`
|
|
2281
|
+
];
|
|
2282
|
+
for (const subtree of changedSubtrees) {
|
|
2283
|
+
const marker = subtree.change === "added" ? "+" : subtree.change === "removed" ? "-" : "~";
|
|
2284
|
+
for (const line of subtree.compact.split("\n")) lines.push(` ${marker} ${line}`);
|
|
2285
|
+
}
|
|
2286
|
+
lines.push(renderLogs({ entries: logs.entries, omitted: logs.omitted, cursor: 0 }));
|
|
2287
|
+
return {
|
|
2288
|
+
text: lines.join("\n"),
|
|
2289
|
+
data: {
|
|
2290
|
+
traceId: trace.id,
|
|
2291
|
+
fromMs: before.timeMs,
|
|
2292
|
+
toMs: after.timeMs,
|
|
2293
|
+
semanticTree: after.semantic === null ? "unavailable" : "available",
|
|
2294
|
+
changedRows: changedRows.map((row) => ({ ...row })),
|
|
2295
|
+
changedSubtrees: changedSubtrees.map((subtree) => ({ ...subtree })),
|
|
2296
|
+
logs: logs.entries.map((entry) => ({ ...entry })),
|
|
2297
|
+
logsOmitted: logs.omitted,
|
|
2298
|
+
compact: lines.join("\n")
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
var TRACE_TOOLS = Object.freeze([open, overview, frame, diff]);
|
|
2304
|
+
|
|
2305
|
+
// src/registry.ts
|
|
2306
|
+
var TOOLS = Object.freeze([...TERMINAL_TOOLS, ...TRACE_TOOLS]);
|
|
2307
|
+
function toolByName(name) {
|
|
2308
|
+
return TOOLS.find((tool) => tool.name === name);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/version.ts
|
|
2312
|
+
var SERVER_NAME = "termwright";
|
|
2313
|
+
var SERVER_VERSION = "0.1.0";
|
|
2314
|
+
var AGENT_CONTEXT_VERSION = 1;
|
|
2315
|
+
|
|
2316
|
+
// src/agent-context.ts
|
|
2317
|
+
import { z as z7 } from "zod";
|
|
2318
|
+
var DRIVER_ERROR_KINDS = [
|
|
2319
|
+
"timeout",
|
|
2320
|
+
"stale-snapshot",
|
|
2321
|
+
"ambiguous-locator",
|
|
2322
|
+
"unsupported-action",
|
|
2323
|
+
"history-truncated",
|
|
2324
|
+
"protocol-violation",
|
|
2325
|
+
"capacity",
|
|
2326
|
+
"process-exited",
|
|
2327
|
+
"session-closed"
|
|
2328
|
+
];
|
|
2329
|
+
var ERROR_KINDS = [...DRIVER_ERROR_KINDS, ...MCP_ERROR_KINDS];
|
|
2330
|
+
var CONVENTIONS = [
|
|
2331
|
+
'A ref looks like n8@42: node id at semantic revision 42. It is valid only while 42 is the live semantic revision; reusing it later fails with kind "stale-snapshot" \u2014 take a fresh snapshot.',
|
|
2332
|
+
"terminal.snapshot returns a screen revision; pass it to terminal.capture_since as cursor to get only the rows and semantic subtrees that changed.",
|
|
2333
|
+
'Any name or text argument may be written as "/pattern/flags" to match as a regular expression.',
|
|
2334
|
+
"Targeting precedence is ref, selector, testId, role (+name), label, text.",
|
|
2335
|
+
'Locators are strict: more than one match fails with kind "ambiguous-locator" unless nth is given.',
|
|
2336
|
+
'semanticTree "unavailable" means the program ships no adapter \u2014 target by text, never by role.',
|
|
2337
|
+
"Errors are returned as tool results with isError set; structuredContent.error.kind is the value to branch on, and structuredContent.error.suggestion says what to try next."
|
|
2338
|
+
];
|
|
2339
|
+
function toJsonSchema(shape) {
|
|
2340
|
+
return z7.toJSONSchema(z7.object(shape), { io: "input" });
|
|
2341
|
+
}
|
|
2342
|
+
function buildAgentContext() {
|
|
2343
|
+
return {
|
|
2344
|
+
v: AGENT_CONTEXT_VERSION,
|
|
2345
|
+
server: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
2346
|
+
tools: TOOLS.map((tool) => ({
|
|
2347
|
+
name: tool.name,
|
|
2348
|
+
title: tool.title,
|
|
2349
|
+
description: tool.description,
|
|
2350
|
+
inputSchema: toJsonSchema(tool.inputSchema),
|
|
2351
|
+
outputSchema: toJsonSchema(tool.outputSchema),
|
|
2352
|
+
annotations: { ...tool.annotations }
|
|
2353
|
+
})),
|
|
2354
|
+
enums: {
|
|
2355
|
+
roles: [...SEMANTIC_ROLES],
|
|
2356
|
+
states: [...STATE_NAMES],
|
|
2357
|
+
signals: [...SIGNALS],
|
|
2358
|
+
errorKinds: ERROR_KINDS
|
|
2359
|
+
},
|
|
2360
|
+
exitCodes: { ...EXIT_CODES },
|
|
2361
|
+
limits: { ...MCP_LIMITS },
|
|
2362
|
+
conventions: [...CONVENTIONS]
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
function buildUsage() {
|
|
2366
|
+
return [
|
|
2367
|
+
`${SERVER_NAME} MCP server ${SERVER_VERSION} \u2014 drive terminal programs over MCP`,
|
|
2368
|
+
"",
|
|
2369
|
+
"serve",
|
|
2370
|
+
" termwright-mcp serve over stdio (what an MCP host spawns)",
|
|
2371
|
+
" termwright-mcp --http --port 7333 serve Streamable HTTP on /mcp, multi-session",
|
|
2372
|
+
" termwright-mcp agent-context versioned JSON: tools, params, enums, exit codes",
|
|
2373
|
+
" termwright-mcp usage this page",
|
|
2374
|
+
" termwright-mcp skill --out DIR emit an agent-skill package (SKILL.md + reference)",
|
|
2375
|
+
" global: --json (machine-readable errors with a kind), --version, --help",
|
|
2376
|
+
"",
|
|
2377
|
+
"typical loop",
|
|
2378
|
+
' terminal.launch {command:["node","app.js"]} -> terminal "t1" + first snapshot',
|
|
2379
|
+
' terminal.snapshot {terminal:"t1"} -> refs n8@42 + visible text + revision',
|
|
2380
|
+
' terminal.click {terminal:"t1", ref:"n8@42"} -> real mouse report through the PTY',
|
|
2381
|
+
' terminal.wait_for {terminal:"t1", wait:"text", text:"Approved"}',
|
|
2382
|
+
' terminal.capture_since {terminal:"t1", cursor:42} -> only what changed',
|
|
2383
|
+
' terminal.close {terminal:"t1"}',
|
|
2384
|
+
"",
|
|
2385
|
+
"targeting ref | selector | testId | role(+name) | label | text (+ exact, state, nth)",
|
|
2386
|
+
`roles ${SEMANTIC_ROLES.join(" ")}`,
|
|
2387
|
+
`states ${STATE_NAMES.join(" ")}`,
|
|
2388
|
+
"",
|
|
2389
|
+
"exit codes 0 ok / 1 assertion / 2 usage / 3 no-session / 4 ipc / 5 internal",
|
|
2390
|
+
"error kinds " + ERROR_KINDS.join(" ")
|
|
2391
|
+
].join("\n");
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// src/agent-skill.ts
|
|
2395
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
2396
|
+
import { join as join2 } from "path";
|
|
2397
|
+
var SKILL_DESCRIPTION = "Drive terminal programs (TUIs, CLIs, REPLs) over MCP: launch one in a real pseudo-terminal, read a compact accessibility-style snapshot, click and type, wait on conditions, and poll only what changed. Use when asked to test, inspect, automate or debug a terminal application.";
|
|
2398
|
+
function renderParameters(schema) {
|
|
2399
|
+
const properties = schema["properties"];
|
|
2400
|
+
if (typeof properties !== "object" || properties === null) return " (no parameters)";
|
|
2401
|
+
const required = new Set(Array.isArray(schema["required"]) ? schema["required"] : []);
|
|
2402
|
+
return Object.entries(properties).map(([name, definition]) => {
|
|
2403
|
+
const type2 = typeof definition["type"] === "string" ? definition["type"] : Array.isArray(definition["enum"]) ? definition["enum"].map((value) => JSON.stringify(value)).join("|") : "value";
|
|
2404
|
+
const description = typeof definition["description"] === "string" ? ` \u2014 ${definition["description"]}` : "";
|
|
2405
|
+
return `- \`${name}\`${required.has(name) ? "" : "?"}: ${type2}${description}`;
|
|
2406
|
+
}).join("\n");
|
|
2407
|
+
}
|
|
2408
|
+
function renderSkillMarkdown() {
|
|
2409
|
+
return [
|
|
2410
|
+
"---",
|
|
2411
|
+
`name: ${SERVER_NAME}`,
|
|
2412
|
+
`description: ${SKILL_DESCRIPTION}`,
|
|
2413
|
+
"---",
|
|
2414
|
+
"",
|
|
2415
|
+
`# Driving terminal programs with ${SERVER_NAME}`,
|
|
2416
|
+
"",
|
|
2417
|
+
"The MCP server exposes one terminal per handle (`t1`, `t2`, \u2026). Every action goes through a",
|
|
2418
|
+
"real pseudo-terminal: a click is a mouse report, a keystroke is real bytes. There is no",
|
|
2419
|
+
"back-channel into the application.",
|
|
2420
|
+
"",
|
|
2421
|
+
"## The loop",
|
|
2422
|
+
"",
|
|
2423
|
+
'1. `terminal.launch { command: ["node", "app.js"] }` \u2014 returns the handle and the first snapshot.',
|
|
2424
|
+
"2. `terminal.snapshot { terminal }` \u2014 compact refs, visible text, cursor, modes, scroll position.",
|
|
2425
|
+
"3. Act: `terminal.click`, `terminal.press`, `terminal.type`, `terminal.paste`, `terminal.drag`, \u2026",
|
|
2426
|
+
'4. `terminal.wait_for { wait: "text" | "visible" | "stable" | "idle" | "exit", \u2026 }` \u2014 never sleep.',
|
|
2427
|
+
"5. `terminal.capture_since { cursor }` \u2014 only the rows and semantic subtrees that changed.",
|
|
2428
|
+
"6. `terminal.close { terminal }`.",
|
|
2429
|
+
"",
|
|
2430
|
+
"## Reading a snapshot",
|
|
2431
|
+
"",
|
|
2432
|
+
"```",
|
|
2433
|
+
"Terminal t1 100x30 revision 42",
|
|
2434
|
+
"semanticTree: available",
|
|
2435
|
+
'dialog "Permission" ref=n7@42 bounds=(8,20,40,9) modal',
|
|
2436
|
+
' button "Approve" ref=n8@42 bounds=(14,23,11,1) focused',
|
|
2437
|
+
"visible text:",
|
|
2438
|
+
"\u2026",
|
|
2439
|
+
"```",
|
|
2440
|
+
"",
|
|
2441
|
+
"The header `revision` is the **screen** revision \u2014 that is the `cursor` for",
|
|
2442
|
+
"`terminal.capture_since`. A ref's number is the **semantic** revision: `n8@42` is valid only",
|
|
2443
|
+
"while 42 is live, and reusing it after the screen moved on fails with `stale-snapshot`. The fix",
|
|
2444
|
+
"is always to snapshot again, never to retry the same ref.",
|
|
2445
|
+
"",
|
|
2446
|
+
"`semanticTree: unavailable` means the program ships no adapter. Target those by `text`; there",
|
|
2447
|
+
"are no invented roles.",
|
|
2448
|
+
"",
|
|
2449
|
+
"## Targeting",
|
|
2450
|
+
"",
|
|
2451
|
+
"Precedence: `ref`, `selector`, `testId`, `role` (+`name`), `label`, `text`. Any name or text may",
|
|
2452
|
+
"be written `/pattern/flags` to match as a regular expression. Locators are strict \u2014 more than one",
|
|
2453
|
+
"match fails with `ambiguous-locator` and lists the candidates; pass `nth` to disambiguate, or use",
|
|
2454
|
+
"`terminal.query` first to see what matches.",
|
|
2455
|
+
"",
|
|
2456
|
+
"## Investigating a recorded failure",
|
|
2457
|
+
"",
|
|
2458
|
+
"A failing test run leaves a `.twtrace` archive. Replay it with the same vocabulary:",
|
|
2459
|
+
"",
|
|
2460
|
+
"1. `trace.open { path }` \u2014 validates the archive, returns a handle and what was recorded.",
|
|
2461
|
+
"2. `trace.overview { traceId }` \u2014 steps with status and timing, markers, exit, which step failed.",
|
|
2462
|
+
"3. `trace.frame_at { traceId, stepIndex | timeMs | marker }` \u2014 the screen at that moment, rebuilt,",
|
|
2463
|
+
" with the semantic tree of the nearest revision. Reads exactly like a live snapshot.",
|
|
2464
|
+
"4. `trace.diff { traceId, fromMs, toMs }` \u2014 what moved between two moments.",
|
|
2465
|
+
"",
|
|
2466
|
+
"Start at the failed step from `trace.overview`, reconstruct it, then diff against a moment before",
|
|
2467
|
+
"it to see what changed. Handles are per session and the coldest is evicted at the ceiling \u2014 if one",
|
|
2468
|
+
"stops resolving, call `trace.open` again.",
|
|
2469
|
+
"",
|
|
2470
|
+
"## The application\u2019s own log",
|
|
2471
|
+
"",
|
|
2472
|
+
"A terminal shows what a program *drew*; its log says what it *decided*. When the screen looks",
|
|
2473
|
+
"right and nothing happened, the answer is usually an `error` or `warn` line, not another pixel.",
|
|
2474
|
+
"",
|
|
2475
|
+
'Pass `logs: [{ path: "app.log", label: "app" }]` to `terminal.launch` and every',
|
|
2476
|
+
"`terminal.capture_since` returns the entries since your cursor, with `logsOmitted` when the",
|
|
2477
|
+
"buffer overflowed and `logCursor` to resume from. An existing file is followed from its end, so",
|
|
2478
|
+
"you never see the previous run.",
|
|
2479
|
+
"",
|
|
2480
|
+
"Two things worth knowing. A followed file is polled, so a line written just now may arrive on the",
|
|
2481
|
+
"next call \u2014 re-asking with the same cursor is lossless, never a reason to widen the window. And a",
|
|
2482
|
+
"log is application output like any other: it can contain tokens and personal data, so treat it",
|
|
2483
|
+
"with the same care as a crash screen tail.",
|
|
2484
|
+
"",
|
|
2485
|
+
"In a replay the same view is there: `trace.frame_at` carries the entries leading up to that",
|
|
2486
|
+
'moment and `trace.diff` the ones between two \u2014 so the question "what was it saying when the',
|
|
2487
|
+
'screen looked like this" is answered the same way live and after the fact.',
|
|
2488
|
+
"",
|
|
2489
|
+
"## Reading terminal modes",
|
|
2490
|
+
"",
|
|
2491
|
+
"A snapshot reports `modes`. Both mouse fields can read `unknown`, which means the platform hides",
|
|
2492
|
+
"the mode from the emulator \u2014 Windows ConPTY does \u2014 not that the program turned mouse reporting",
|
|
2493
|
+
"off. Clicks still go through there, encoded as SGR. So `unknown` is never a reason to fall back",
|
|
2494
|
+
"to keyboard-only interaction; a real `none` is.",
|
|
2495
|
+
"",
|
|
2496
|
+
"## Screenshots",
|
|
2497
|
+
"",
|
|
2498
|
+
"`terminal.snapshot` and `trace.frame_at` take `screenshot: true` (plus `screenshotScale` and",
|
|
2499
|
+
"`screenshotTheme`) and attach a PNG. The text and the compact tree come back in the same result,",
|
|
2500
|
+
"so ask for a picture only when pixels answer something the text cannot \u2014 alignment, colour, a",
|
|
2501
|
+
"glyph that looks wrong. Check `structuredContent.screenshot.selfContained`: when false, some",
|
|
2502
|
+
"character fell back to a font that may not exist where the image is viewed.",
|
|
2503
|
+
"",
|
|
2504
|
+
"## When the program dies",
|
|
2505
|
+
"",
|
|
2506
|
+
"A program that exits on its own \u2014 a signal, or a non-zero code nobody asked for \u2014 leaves a crash",
|
|
2507
|
+
"report. It rides along with whatever call failed next, and `terminal.capabilities` /",
|
|
2508
|
+
"`terminal.snapshot` show it instead of reporting a merely closed session. Read the exit status and",
|
|
2509
|
+
"the screen tail before retrying: a locator that never resolved because the program is gone looks",
|
|
2510
|
+
"like a timeout, and waiting longer will not bring it back. `trace.overview` shows the same section",
|
|
2511
|
+
"for a recording that carries one.",
|
|
2512
|
+
"",
|
|
2513
|
+
"**The screen tail is unredacted.** It is whatever the terminal displayed at the end \u2014 a stack",
|
|
2514
|
+
"trace, a config dump, an echoed password, whatever was there. Treat it like a screenshot of the",
|
|
2515
|
+
"user's machine: use it to diagnose, but do not paste it into issues, commit messages, chat",
|
|
2516
|
+
"transcripts or anywhere else it outlives the investigation. The one thing never recorded is the",
|
|
2517
|
+
"content of a paste \u2014 those carry secrets routinely, so only their size is kept.",
|
|
2518
|
+
"",
|
|
2519
|
+
"## When a call fails",
|
|
2520
|
+
"",
|
|
2521
|
+
"Failures come back with `isError` and a text block starting `error <kind>: <message>` plus a",
|
|
2522
|
+
"`suggestion`. Branch on the kind: `stale-snapshot` (re-snapshot), `ambiguous-locator` (narrow the",
|
|
2523
|
+
"target), `timeout` (the condition never held \u2014 read the screen excerpt), `unsupported-action` (the",
|
|
2524
|
+
"program never enabled mouse tracking, or has no semantic tree), `no-session` (bad handle),",
|
|
2525
|
+
'`history-truncated` (cursor too old). The same payload is in `_meta["io.termwright/error"]`.',
|
|
2526
|
+
"",
|
|
2527
|
+
"See `reference.md` for every tool and parameter, and `agent-context.json` for the machine-readable",
|
|
2528
|
+
"surface (enums, defaults, exit codes).",
|
|
2529
|
+
"",
|
|
2530
|
+
"## Command line",
|
|
2531
|
+
"",
|
|
2532
|
+
"```",
|
|
2533
|
+
buildUsage().split("\n").slice(2).join("\n").trim(),
|
|
2534
|
+
"```",
|
|
2535
|
+
""
|
|
2536
|
+
].join("\n");
|
|
2537
|
+
}
|
|
2538
|
+
function renderReferenceMarkdown() {
|
|
2539
|
+
const context = buildAgentContext();
|
|
2540
|
+
const sections = context.tools.map(
|
|
2541
|
+
(tool) => [
|
|
2542
|
+
`## ${tool.name}`,
|
|
2543
|
+
"",
|
|
2544
|
+
tool.description,
|
|
2545
|
+
"",
|
|
2546
|
+
"**Input**",
|
|
2547
|
+
renderParameters(tool.inputSchema),
|
|
2548
|
+
"",
|
|
2549
|
+
"**Output**",
|
|
2550
|
+
renderParameters(tool.outputSchema),
|
|
2551
|
+
""
|
|
2552
|
+
].join("\n")
|
|
2553
|
+
);
|
|
2554
|
+
return [
|
|
2555
|
+
`# ${SERVER_NAME} tool reference (${SERVER_VERSION})`,
|
|
2556
|
+
"",
|
|
2557
|
+
`Generated from the zod schemas of ${TOOLS.length} tools; do not edit by hand.`,
|
|
2558
|
+
"",
|
|
2559
|
+
...sections,
|
|
2560
|
+
"## Exit codes",
|
|
2561
|
+
"",
|
|
2562
|
+
Object.entries(context.exitCodes).map(([name, code]) => `- ${String(code)} \u2014 ${name}`).join("\n"),
|
|
2563
|
+
"",
|
|
2564
|
+
"## Error kinds",
|
|
2565
|
+
"",
|
|
2566
|
+
context.enums.errorKinds.map((kind) => `- \`${kind}\``).join("\n"),
|
|
2567
|
+
""
|
|
2568
|
+
].join("\n");
|
|
2569
|
+
}
|
|
2570
|
+
function buildAgentSkill() {
|
|
2571
|
+
return [
|
|
2572
|
+
{ path: "SKILL.md", contents: renderSkillMarkdown() },
|
|
2573
|
+
{ path: "reference.md", contents: renderReferenceMarkdown() },
|
|
2574
|
+
{ path: "agent-context.json", contents: `${JSON.stringify(buildAgentContext(), null, 2)}
|
|
2575
|
+
` }
|
|
2576
|
+
];
|
|
2577
|
+
}
|
|
2578
|
+
async function writeAgentSkill(directory) {
|
|
2579
|
+
await mkdir2(directory, { recursive: true });
|
|
2580
|
+
const written = [];
|
|
2581
|
+
for (const file of buildAgentSkill()) {
|
|
2582
|
+
const path = join2(directory, file.path);
|
|
2583
|
+
await writeFile2(path, file.contents, "utf8");
|
|
2584
|
+
written.push(path);
|
|
2585
|
+
}
|
|
2586
|
+
return written;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// src/server.ts
|
|
2590
|
+
import { createServer } from "http";
|
|
2591
|
+
import { randomUUID } from "crypto";
|
|
2592
|
+
|
|
2593
|
+
// src/sdk-facade.ts
|
|
2594
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2595
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2596
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2597
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
2598
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2599
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2600
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
2601
|
+
async function connectTransport(server, transport) {
|
|
2602
|
+
await server.connect(transport);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// src/server.ts
|
|
2606
|
+
var INSTRUCTIONS = "Drive terminal programs the way a person would. terminal.launch starts a program and returns a handle; terminal.snapshot gives compact refs plus visible text; act with terminal.click / press / type; wait with terminal.wait_for; poll cheaply with terminal.capture_since using the revision a snapshot returned. Refs like n8@42 are only valid at semantic revision 42 \u2014 re-snapshot after the screen changes. Programs without a termwright adapter report semanticTree: unavailable; target them by text instead of by role.";
|
|
2607
|
+
function successResult(outcome) {
|
|
2608
|
+
return {
|
|
2609
|
+
content: [
|
|
2610
|
+
{ type: "text", text: outcome.text },
|
|
2611
|
+
...(outcome.images ?? []).map((image) => ({
|
|
2612
|
+
type: "image",
|
|
2613
|
+
data: image.data,
|
|
2614
|
+
mimeType: image.mimeType
|
|
2615
|
+
}))
|
|
2616
|
+
],
|
|
2617
|
+
structuredContent: outcome.data
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
function withCrashContext(context, args, error) {
|
|
2621
|
+
if (error instanceof CrashContextError) return error;
|
|
2622
|
+
const id = args?.terminal;
|
|
2623
|
+
if (typeof id !== "string") return error;
|
|
2624
|
+
const report = context.terminals.find(id)?.harness.crashReport();
|
|
2625
|
+
return report === void 0 || report === null ? error : new CrashContextError(error, describeCrash(report));
|
|
2626
|
+
}
|
|
2627
|
+
var ERROR_META_KEY = "io.termwright/error";
|
|
2628
|
+
function errorResult(error) {
|
|
2629
|
+
const payload = toErrorPayload(error);
|
|
2630
|
+
return {
|
|
2631
|
+
isError: true,
|
|
2632
|
+
content: [{ type: "text", text: renderErrorPayload(payload) }],
|
|
2633
|
+
_meta: { [ERROR_META_KEY]: payload }
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
function createTermwrightMcpServer(stores) {
|
|
2637
|
+
const server = new McpServer(
|
|
2638
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
2639
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
2640
|
+
);
|
|
2641
|
+
const context = { terminals: stores.terminals, traces: stores.traces };
|
|
2642
|
+
for (const tool of TOOLS) {
|
|
2643
|
+
server.registerTool(
|
|
2644
|
+
tool.name,
|
|
2645
|
+
{
|
|
2646
|
+
title: tool.title,
|
|
2647
|
+
description: tool.description,
|
|
2648
|
+
inputSchema: tool.inputSchema,
|
|
2649
|
+
outputSchema: tool.outputSchema,
|
|
2650
|
+
annotations: tool.annotations
|
|
2651
|
+
},
|
|
2652
|
+
async (args) => {
|
|
2653
|
+
try {
|
|
2654
|
+
return successResult(await tool.handler(context, args));
|
|
2655
|
+
} catch (error) {
|
|
2656
|
+
return errorResult(withCrashContext(context, args, error));
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
);
|
|
2660
|
+
}
|
|
2661
|
+
return server;
|
|
2662
|
+
}
|
|
2663
|
+
async function connect(stores, transport) {
|
|
2664
|
+
const server = createTermwrightMcpServer(stores);
|
|
2665
|
+
await connectTransport(server, transport);
|
|
2666
|
+
return {
|
|
2667
|
+
server,
|
|
2668
|
+
stores,
|
|
2669
|
+
close: async () => {
|
|
2670
|
+
await closeSessionStores(stores);
|
|
2671
|
+
await server.close();
|
|
2672
|
+
}
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
async function serveStdio(options = {}) {
|
|
2676
|
+
const stores = createSessionStores({ sessionKey: "stdio", storageDir: options.storageDir });
|
|
2677
|
+
return connect(stores, new StdioServerTransport());
|
|
2678
|
+
}
|
|
2679
|
+
async function serveInMemory(options = {}) {
|
|
2680
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
2681
|
+
const stores = createSessionStores({
|
|
2682
|
+
sessionKey: options.sessionKey ?? "in-memory",
|
|
2683
|
+
storageDir: options.storageDir
|
|
2684
|
+
});
|
|
2685
|
+
const running = await connect(stores, serverTransport);
|
|
2686
|
+
return { ...running, clientTransport };
|
|
2687
|
+
}
|
|
2688
|
+
var DEFAULT_IDLE_TTL_MS = 10 * 6e4;
|
|
2689
|
+
function sendJson(response, status, body) {
|
|
2690
|
+
const text = JSON.stringify(body);
|
|
2691
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
2692
|
+
response.end(text);
|
|
2693
|
+
}
|
|
2694
|
+
async function readBody(request) {
|
|
2695
|
+
const chunks = [];
|
|
2696
|
+
let size = 0;
|
|
2697
|
+
for await (const chunk of request) {
|
|
2698
|
+
const buffer = Buffer.from(chunk);
|
|
2699
|
+
size += buffer.byteLength;
|
|
2700
|
+
if (size > 4 * 1024 * 1024) throw new Error("request body too large");
|
|
2701
|
+
chunks.push(buffer);
|
|
2702
|
+
}
|
|
2703
|
+
if (chunks.length === 0) return void 0;
|
|
2704
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2705
|
+
}
|
|
2706
|
+
async function serveHttp(options = {}) {
|
|
2707
|
+
const path = options.path ?? "/mcp";
|
|
2708
|
+
const log = options.log ?? ((message) => void process.stderr.write(`${message}
|
|
2709
|
+
`));
|
|
2710
|
+
const registry = new SessionRegistry({
|
|
2711
|
+
...options.maxSessions === void 0 ? {} : { maxSessions: options.maxSessions },
|
|
2712
|
+
...options.storageDir === void 0 ? {} : { storageDir: options.storageDir },
|
|
2713
|
+
...options.now === void 0 ? {} : { now: options.now },
|
|
2714
|
+
idleTtlMs: options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS,
|
|
2715
|
+
disposeAttachment: async (attachment) => {
|
|
2716
|
+
await attachment.transport.close();
|
|
2717
|
+
},
|
|
2718
|
+
onExpired: (key) => {
|
|
2719
|
+
log(`termwright: session ${key} expired after idling; terminals and traces released`);
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
registry.startIdleSweeper();
|
|
2723
|
+
const http = createServer((request, response) => {
|
|
2724
|
+
void (async () => {
|
|
2725
|
+
try {
|
|
2726
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
2727
|
+
if (url.pathname !== path) {
|
|
2728
|
+
sendJson(response, 404, { error: "not found" });
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
2732
|
+
const key = Array.isArray(sessionId) ? sessionId[0] : sessionId;
|
|
2733
|
+
if (request.method === "DELETE") {
|
|
2734
|
+
if (key !== void 0) await registry.delete(key);
|
|
2735
|
+
response.writeHead(204).end();
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
const body = request.method === "POST" ? await readBody(request) : void 0;
|
|
2739
|
+
if (key !== void 0) {
|
|
2740
|
+
const session2 = registry.get(key);
|
|
2741
|
+
if (session2 === void 0) {
|
|
2742
|
+
sendJson(response, 404, { error: "unknown session", kind: "no-session" });
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
registry.touch(key);
|
|
2746
|
+
await session2.attachment.transport.handleRequest(request, response, body);
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (request.method !== "POST" || !isInitializeRequest(body)) {
|
|
2750
|
+
sendJson(response, 400, { error: "missing Mcp-Session-Id", kind: "usage" });
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const newKey = randomUUID();
|
|
2754
|
+
const session = registry.create(newKey, (stores) => {
|
|
2755
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2756
|
+
sessionIdGenerator: () => newKey
|
|
2757
|
+
});
|
|
2758
|
+
const server = createTermwrightMcpServer(stores);
|
|
2759
|
+
transport.onclose = () => {
|
|
2760
|
+
void registry.delete(newKey);
|
|
2761
|
+
};
|
|
2762
|
+
return { transport, server };
|
|
2763
|
+
});
|
|
2764
|
+
await connectTransport(session.attachment.server, session.attachment.transport);
|
|
2765
|
+
await session.attachment.transport.handleRequest(request, response, body);
|
|
2766
|
+
} catch (error) {
|
|
2767
|
+
const payload = toErrorPayload(error);
|
|
2768
|
+
if (!response.headersSent) sendJson(response, 500, { error: payload.message, kind: payload.kind });
|
|
2769
|
+
else response.end();
|
|
2770
|
+
}
|
|
2771
|
+
})();
|
|
2772
|
+
});
|
|
2773
|
+
await new Promise((resolve) => {
|
|
2774
|
+
http.listen(options.port ?? 0, options.host ?? "127.0.0.1", resolve);
|
|
2775
|
+
});
|
|
2776
|
+
const address = http.address();
|
|
2777
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port ?? 0;
|
|
2778
|
+
return {
|
|
2779
|
+
http,
|
|
2780
|
+
registry,
|
|
2781
|
+
port,
|
|
2782
|
+
close: async () => {
|
|
2783
|
+
registry.stopIdleSweeper();
|
|
2784
|
+
await registry.closeAll();
|
|
2785
|
+
await new Promise((resolve) => {
|
|
2786
|
+
http.close(() => {
|
|
2787
|
+
resolve();
|
|
2788
|
+
});
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// src/cli.ts
|
|
2795
|
+
var defaultIo = {
|
|
2796
|
+
out: (text) => process.stdout.write(`${text}
|
|
2797
|
+
`),
|
|
2798
|
+
err: (text) => process.stderr.write(`${text}
|
|
2799
|
+
`)
|
|
2800
|
+
};
|
|
2801
|
+
function parseArgs(argv) {
|
|
2802
|
+
let command = "serve";
|
|
2803
|
+
let json = false;
|
|
2804
|
+
let http = false;
|
|
2805
|
+
let port;
|
|
2806
|
+
let host;
|
|
2807
|
+
let out;
|
|
2808
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2809
|
+
const arg = argv[index] ?? "";
|
|
2810
|
+
switch (arg) {
|
|
2811
|
+
case "--json":
|
|
2812
|
+
json = true;
|
|
2813
|
+
break;
|
|
2814
|
+
case "--http":
|
|
2815
|
+
http = true;
|
|
2816
|
+
break;
|
|
2817
|
+
case "--port": {
|
|
2818
|
+
const value = Number(argv[index + 1]);
|
|
2819
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
2820
|
+
throw usageError("--port needs an integer between 0 and 65535");
|
|
2821
|
+
}
|
|
2822
|
+
port = value;
|
|
2823
|
+
index += 1;
|
|
2824
|
+
break;
|
|
2825
|
+
}
|
|
2826
|
+
case "--host":
|
|
2827
|
+
host = argv[index + 1];
|
|
2828
|
+
if (host === void 0) throw usageError("--host needs a value");
|
|
2829
|
+
index += 1;
|
|
2830
|
+
break;
|
|
2831
|
+
case "--out":
|
|
2832
|
+
out = argv[index + 1];
|
|
2833
|
+
if (out === void 0) throw usageError("--out needs a directory");
|
|
2834
|
+
index += 1;
|
|
2835
|
+
break;
|
|
2836
|
+
case "--help":
|
|
2837
|
+
case "-h":
|
|
2838
|
+
command = "help";
|
|
2839
|
+
break;
|
|
2840
|
+
case "--version":
|
|
2841
|
+
case "-v":
|
|
2842
|
+
command = "version";
|
|
2843
|
+
break;
|
|
2844
|
+
case "serve":
|
|
2845
|
+
case "stdio":
|
|
2846
|
+
command = "serve";
|
|
2847
|
+
break;
|
|
2848
|
+
case "agent-context":
|
|
2849
|
+
command = "agent-context";
|
|
2850
|
+
break;
|
|
2851
|
+
case "usage":
|
|
2852
|
+
command = "usage";
|
|
2853
|
+
break;
|
|
2854
|
+
case "skill":
|
|
2855
|
+
command = "skill";
|
|
2856
|
+
break;
|
|
2857
|
+
default:
|
|
2858
|
+
throw usageError(
|
|
2859
|
+
`unknown argument ${JSON.stringify(arg)}`,
|
|
2860
|
+
"run `termwright-mcp usage` for the one-screen cheat sheet"
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
return { command, json, http, port, host, out };
|
|
2865
|
+
}
|
|
2866
|
+
async function runCli(argv, io = defaultIo) {
|
|
2867
|
+
let json = argv.includes("--json");
|
|
2868
|
+
try {
|
|
2869
|
+
const args = parseArgs(argv);
|
|
2870
|
+
json = args.json;
|
|
2871
|
+
switch (args.command) {
|
|
2872
|
+
case "version":
|
|
2873
|
+
io.out(json ? JSON.stringify({ name: SERVER_NAME, version: SERVER_VERSION }) : SERVER_VERSION);
|
|
2874
|
+
return EXIT_CODES.ok;
|
|
2875
|
+
case "help":
|
|
2876
|
+
case "usage":
|
|
2877
|
+
io.out(json ? JSON.stringify(buildAgentContext()) : buildUsage());
|
|
2878
|
+
return EXIT_CODES.ok;
|
|
2879
|
+
case "agent-context":
|
|
2880
|
+
io.out(JSON.stringify(buildAgentContext(), null, json ? 0 : 2));
|
|
2881
|
+
return EXIT_CODES.ok;
|
|
2882
|
+
case "skill": {
|
|
2883
|
+
if (args.out === void 0) {
|
|
2884
|
+
const files = buildAgentSkill();
|
|
2885
|
+
io.out(
|
|
2886
|
+
json ? JSON.stringify(Object.fromEntries(files.map((file) => [file.path, file.contents]))) : files.map((file) => `=== ${file.path}
|
|
2887
|
+
${file.contents}`).join("\n")
|
|
2888
|
+
);
|
|
2889
|
+
return EXIT_CODES.ok;
|
|
2890
|
+
}
|
|
2891
|
+
const written = await writeAgentSkill(args.out);
|
|
2892
|
+
io.out(json ? JSON.stringify({ written }) : written.join("\n"));
|
|
2893
|
+
return EXIT_CODES.ok;
|
|
2894
|
+
}
|
|
2895
|
+
case "serve": {
|
|
2896
|
+
if (args.http) {
|
|
2897
|
+
const handle = await serveHttp({
|
|
2898
|
+
...args.port === void 0 ? {} : { port: args.port },
|
|
2899
|
+
...args.host === void 0 ? {} : { host: args.host }
|
|
2900
|
+
});
|
|
2901
|
+
io.err(`${SERVER_NAME} MCP listening on http://${args.host ?? "127.0.0.1"}:${handle.port}/mcp`);
|
|
2902
|
+
await new Promise((resolve) => {
|
|
2903
|
+
handle.http.on("close", resolve);
|
|
2904
|
+
});
|
|
2905
|
+
return EXIT_CODES.ok;
|
|
2906
|
+
}
|
|
2907
|
+
const running = await serveStdio();
|
|
2908
|
+
await new Promise((resolve) => {
|
|
2909
|
+
const shutdown = () => {
|
|
2910
|
+
void running.close().then(resolve, resolve);
|
|
2911
|
+
};
|
|
2912
|
+
process.once("SIGINT", shutdown);
|
|
2913
|
+
process.once("SIGTERM", shutdown);
|
|
2914
|
+
running.server.server.onclose = shutdown;
|
|
2915
|
+
});
|
|
2916
|
+
return EXIT_CODES.ok;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
} catch (error) {
|
|
2920
|
+
const payload = toErrorPayload(error);
|
|
2921
|
+
io.err(json ? JSON.stringify(payload) : `${payload.kind}: ${payload.message}`);
|
|
2922
|
+
if (!json && payload.suggestion !== void 0) io.err(`suggestion: ${payload.suggestion}`);
|
|
2923
|
+
return exitCodeFor(payload.kind);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
async function main() {
|
|
2927
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
export {
|
|
2931
|
+
EXIT_CODES,
|
|
2932
|
+
exitCodeFor,
|
|
2933
|
+
McpError,
|
|
2934
|
+
usageError,
|
|
2935
|
+
noSessionError,
|
|
2936
|
+
toErrorPayload,
|
|
2937
|
+
renderErrorPayload,
|
|
2938
|
+
SEMANTIC_ROLES,
|
|
2939
|
+
FILTERABLE_STATES,
|
|
2940
|
+
SIGNALS,
|
|
2941
|
+
TRACE_LIMITS,
|
|
2942
|
+
TraceStore,
|
|
2943
|
+
MCP_LIMITS,
|
|
2944
|
+
TerminalStore,
|
|
2945
|
+
createSessionStores,
|
|
2946
|
+
closeSessionStores,
|
|
2947
|
+
SessionRegistry,
|
|
2948
|
+
defineTool,
|
|
2949
|
+
SCREENSHOT_LIMITS,
|
|
2950
|
+
renderScreenshot,
|
|
2951
|
+
formatRef,
|
|
2952
|
+
parseRef,
|
|
2953
|
+
formatBounds,
|
|
2954
|
+
stateFlags,
|
|
2955
|
+
formatNodeLine,
|
|
2956
|
+
walkSnapshot,
|
|
2957
|
+
refEntries,
|
|
2958
|
+
toRefEntry,
|
|
2959
|
+
formatCompactSnapshot,
|
|
2960
|
+
diffRows,
|
|
2961
|
+
diffSemantic,
|
|
2962
|
+
textOrRegExp,
|
|
2963
|
+
buildLocator,
|
|
2964
|
+
TERMINAL_TOOLS,
|
|
2965
|
+
TRACE_TOOLS,
|
|
2966
|
+
TOOLS,
|
|
2967
|
+
toolByName,
|
|
2968
|
+
SERVER_NAME,
|
|
2969
|
+
SERVER_VERSION,
|
|
2970
|
+
AGENT_CONTEXT_VERSION,
|
|
2971
|
+
buildAgentContext,
|
|
2972
|
+
buildUsage,
|
|
2973
|
+
buildAgentSkill,
|
|
2974
|
+
writeAgentSkill,
|
|
2975
|
+
createTermwrightMcpServer,
|
|
2976
|
+
serveStdio,
|
|
2977
|
+
serveInMemory,
|
|
2978
|
+
serveHttp,
|
|
2979
|
+
runCli,
|
|
2980
|
+
main
|
|
2981
|
+
};
|
|
2982
|
+
//# sourceMappingURL=chunk-QDIAASH7.js.map
|