@nexus-aethra/dsh-box-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,2231 @@
1
+ import { DshboxRpc, DshboxUnavailableError, discoveryPath, getRpc, resetRpc } from "./rpc.mjs";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ //#region src/tasks.ts
5
+ /**
6
+ * Following a dshbox background task to its outcome.
7
+ *
8
+ * Most of what an agent wants to do to a box -- start a container, build a
9
+ * template, inject a resource -- is not an operation but a *task*: the daemon
10
+ * answers with a task record and then does the work. That split is right for a
11
+ * UI, which can watch a progress bar. For a caller with no other channel it
12
+ * means one call returns an id and no outcome, and the next thing to write is
13
+ * the polling loop. That loop is the learning cost this removes.
14
+ *
15
+ * So the tools enqueue and wait together, and hand back the finished record:
16
+ * state, progress, the log path, and the error when there was one.
17
+ *
18
+ * The state machine is the part worth being careful about. A task that fails
19
+ * may still be running: it rolls back first, and only then is finished. So
20
+ * `failed` alone does not mean done -- treating it as terminal returns while
21
+ * rollback is still releasing locks, and the caller then starts the next
22
+ * operation against a store that is still being unwound.
23
+ *
24
+ * @module dsh-box-sandbox/tasks
25
+ */
26
+ /**
27
+ * The daemon's wire form for a task state.
28
+ *
29
+ * `TaskState` is declared in Rust with `#[serde(rename_all = "lowercase")]`, so
30
+ * what crosses the wire is `succeeded`, `rolledback`, `cancelled` -- not the
31
+ * PascalCase the enum spells in source. Comparing against the Rust spelling
32
+ * matches nothing: a finished task is not recognised as finished, so a poll
33
+ * runs to its full budget and then reports a task that is sitting at 100%
34
+ * successful as one that did not finish. Normalising here rather than at each
35
+ * call site is also what makes the two separators irrelevant, so a future
36
+ * `rolling_back` cannot reintroduce the same silence a different way.
37
+ */
38
+ function state(task) {
39
+ return task.status.trim().toLowerCase().replace(/[\s_-]/g, "");
40
+ }
41
+ /** States the daemon will not move out of. */
42
+ const TERMINAL = /* @__PURE__ */ new Set([
43
+ "succeeded",
44
+ "cancelled",
45
+ "interrupted",
46
+ "rolledback"
47
+ ]);
48
+ /**
49
+ * True once a task will not change again.
50
+ *
51
+ * `Failed` is the exception the daemon's own docs call out: a failed task rolls
52
+ * back, and only a failed *rollback* ends it. So `failed` on its own is not
53
+ * the end of a task -- polling on it alone waits out the budget and then reports
54
+ * a task that stopped as one that never stopped.
55
+ *
56
+ * What actually says the failure is over is the daemon's own `finishedAt`: it is
57
+ * stamped when the task, rollback included, is done, and it stays null while a
58
+ * rollback is still releasing locks. A task carrying one will not be handed to a
59
+ * worker again, so treating it as finished is not a guess about the state
60
+ * machine -- it is reading the same field the scheduler writes.
61
+ */
62
+ function isFinished(task) {
63
+ const current = state(task);
64
+ if (TERMINAL.has(current)) return true;
65
+ if (current === "failed") {
66
+ const rollback = task.rollbackError;
67
+ if (typeof rollback === "string" && rollback.length > 0) return true;
68
+ return typeof task.finishedAt === "number" && task.finishedAt > 0;
69
+ }
70
+ return false;
71
+ }
72
+ /** True when the task stopped without doing what was asked. */
73
+ function failed(task) {
74
+ const current = state(task);
75
+ return current === "failed" || current === "interrupted" || current === "rolledback";
76
+ }
77
+ /**
78
+ * Poll a task until it finishes.
79
+ *
80
+ * A timeout is reported as such rather than as a result: handing back a task
81
+ * that is still Queued, under a heading that reads like an outcome, is how a
82
+ * caller goes on to assume something that has not happened yet. The last
83
+ * known state comes back with it so the caller can say where it got to.
84
+ */
85
+ async function waitForTask(id, options = {}) {
86
+ const timeoutMs = options.timeoutMs ?? 6e5;
87
+ const intervalMs = options.intervalMs ?? 700;
88
+ const deadline = Date.now() + timeoutMs;
89
+ const rpc = getRpc();
90
+ for (;;) {
91
+ const task = await rpc.call("task_status", { id });
92
+ if (isFinished(task)) return {
93
+ finished: true,
94
+ task
95
+ };
96
+ if (Date.now() >= deadline) return {
97
+ finished: false,
98
+ task
99
+ };
100
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
101
+ }
102
+ }
103
+ /**
104
+ * Enqueue a task and follow it to the end.
105
+ *
106
+ * Returns the finished record, or throws with the daemon's own error text once
107
+ * the task is finished and failed. The log path rides along in the message
108
+ * because that is where the detail is, and an agent that has to go looking for
109
+ * it is an agent that will not.
110
+ */
111
+ async function runTask(method, params, options = {}) {
112
+ const { finished, task } = await waitForTask((await getRpc().call(method, params)).id, options);
113
+ if (!finished) throw new Error(`the ${task.kind} task did not finish within the budget; it is still ${task.status} at ${task.progress}%. Watch it with box_task, or read ${task.logPath ?? "its log"}.`);
114
+ if (failed(task)) throw new Error(`the ${task.kind} task ended ${task.status}` + (task.error ? `: ${task.error}` : "") + (task.rollbackError ? ` (rollback also failed: ${task.rollbackError})` : "") + (task.logPath ? `. Full detail in ${task.logPath}` : "."));
115
+ return task;
116
+ }
117
+ //#endregion
118
+ //#region src/tools.ts
119
+ /**
120
+ * Reach the two host services this plugin needs.
121
+ *
122
+ * This is the only place the structural contract touches cordis. Keeping the
123
+ * assertion here means the rest of the file is checked against the transcribed
124
+ * types, so a shape change surfaces as one cast rather than a scatter of them.
125
+ */
126
+ function hostServices(ctx) {
127
+ return ctx;
128
+ }
129
+ /** Long enough for a cold browser launch on the first call of a session. */
130
+ const LAUNCH_BUDGET_MS = 9e4;
131
+ /** A click or a query against an already-warm session is fast. */
132
+ const WARM_BUDGET_MS = 3e4;
133
+ function containerIdOf(args) {
134
+ const id = args?.containerId;
135
+ if (typeof id !== "string" || id.trim() === "") throw new Error("containerId is required");
136
+ return id;
137
+ }
138
+ function asRecord(value) {
139
+ return typeof value === "object" && value !== null ? value : {};
140
+ }
141
+ function textOf(value) {
142
+ const raw = asRecord(value).text;
143
+ return typeof raw === "string" ? raw : void 0;
144
+ }
145
+ /**
146
+ * Call a debug RPC, opening the container's headless session if needed.
147
+ *
148
+ * The daemon keeps one session per container, so a missing session is the
149
+ * normal first-call state rather than an error worth surfacing. Re-opening
150
+ * is idempotent, which also recovers a session whose browser died.
151
+ */
152
+ async function withSession(id, method, params) {
153
+ const rpc = getRpc();
154
+ try {
155
+ return await rpc.call(method, {
156
+ id,
157
+ ...params
158
+ });
159
+ } catch (error) {
160
+ if (!/no debug session/i.test(String(error))) throw error;
161
+ await rpc.call("debug_open", { id });
162
+ return await rpc.call(method, {
163
+ id,
164
+ ...params
165
+ });
166
+ }
167
+ }
168
+ function base64Of(value) {
169
+ const data = asRecord(value).data;
170
+ if (typeof data !== "string") throw new Error("screenshot result carried no image data");
171
+ return data;
172
+ }
173
+ /**
174
+ * Register one tool against the host registry.
175
+ *
176
+ * Kept as a named function so each definition is typed as ToolDefinition at the
177
+ * call site, which is what makes a missing field a compile error here rather
178
+ * than a silent failure the first time the model calls the tool.
179
+ */
180
+ function registerTool(ctx, definition) {
181
+ hostServices(ctx).tools.register(definition);
182
+ }
183
+ function renderPageElements(elements) {
184
+ if (elements.length === 0) return "No elements on this page.";
185
+ return elements.map((element, index) => {
186
+ const role = element.role ?? "?";
187
+ const name = element.name ?? "";
188
+ const value = element.value ? " value=" + JSON.stringify(element.value) : "";
189
+ const at = Math.round(element.x ?? 0) + "," + Math.round(element.y ?? 0);
190
+ const size = element.width !== void 0 && element.height !== void 0 ? " " + Math.round(element.width) + "x" + Math.round(element.height) : "";
191
+ const off = element.inViewport === false ? " [BELOW FOLD - scroll first]" : "";
192
+ const state = [
193
+ element.disabled === true ? "DISABLED" : "",
194
+ element.required === true ? "required" : "",
195
+ element.invalid === true ? "invalid" : "",
196
+ element.checked === true ? "checked" : "",
197
+ element.expanded === false ? "collapsed" : ""
198
+ ].filter((flag) => flag !== "").join(",");
199
+ const marks = state === "" ? "" : " [" + state + "]";
200
+ return "[" + index + "] " + role + " " + JSON.stringify(name) + value + size + " at (" + at + ")" + off + marks;
201
+ }).join("\n");
202
+ }
203
+ /**
204
+ * The one line that keeps a response self-teaching.
205
+ *
206
+ * An agent meeting this toolset for the first time should learn the next
207
+ * move from the response it already has, not from a manual it will never
208
+ * read. So every tool ends by naming the call that usually follows.
209
+ */
210
+ function nextHint(next) {
211
+ return "\nnext: " + next;
212
+ }
213
+ /**
214
+ * Render a click, distinguishing the two outcomes an agent must not confuse.
215
+ *
216
+ * A dispatched event is not a delivered one. When something covers the target
217
+ * -- an overlay, a cookie bar, an extension injected into the page -- the click
218
+ * reaches that instead and the page appears to do nothing, which reads as a
219
+ * broken app rather than a blocked click. Naming the cover turns a dead end
220
+ * into a decision: scroll, dismiss, or target the overlay itself.
221
+ */
222
+ function renderClick(value, target) {
223
+ const record = asRecord(value);
224
+ if (record.landed === false) {
225
+ const cover = record.occludedBy ? " it was covered by <" + String(record.occludedBy) + ">" + (record.hitText ? " " + JSON.stringify(record.hitText) : "") : " something else was on top of it";
226
+ return [{
227
+ type: "text",
228
+ text: "The click on " + target + " in container " + String(record.containerId) + " did NOT land:" + cover + "." + nextHint("scroll with box_scroll, or dismiss the covering element, then click again")
229
+ }];
230
+ }
231
+ return [{
232
+ type: "text",
233
+ text: "Clicked " + target + " in container " + String(record.containerId) + "." + nextHint("call box_page_text to see what the page looks like now")
234
+ }];
235
+ }
236
+ function applyBoxTools(ctx) {
237
+ registerTool(ctx, {
238
+ name: "box_screenshot",
239
+ description: "Save what a running dshbox container currently renders as a PNG, and report where.This is the one tool whose picture you do not get: a tool result is assistant-side contentand the host sends text only on that side, so you receive the dimensions and nothing else.Asking yourself to look at the image asks for something that cannot arrive, and re-readingthe file path does not change that. So take one to leave something a person can look at,and use box_page_text to find out what is on the page. In practice: describe the page inyour answer, and save a PNG when the layout or a rendering is what is worth showingrather than describing.",
240
+ parameters: {
241
+ type: "object",
242
+ properties: {
243
+ containerId: {
244
+ type: "string",
245
+ description: "Container id to look at, e.g. from a container list."
246
+ },
247
+ fullPage: {
248
+ type: "boolean",
249
+ description: "Capture the whole scrollable page instead of the viewport. Defaults to false."
250
+ }
251
+ },
252
+ required: ["containerId"]
253
+ },
254
+ timeoutMs: LAUNCH_BUDGET_MS,
255
+ output: {
256
+ schema: {
257
+ type: "object",
258
+ additionalProperties: false,
259
+ properties: {
260
+ containerId: { type: "string" },
261
+ attachment: { type: "object" }
262
+ },
263
+ required: ["containerId", "attachment"]
264
+ },
265
+ render: (_args, value) => {
266
+ const record = asRecord(value);
267
+ const shot = asRecord(record.attachment);
268
+ return [{
269
+ type: "text",
270
+ text: "Screenshot of container " + String(record.containerId) + " at " + String(shot.width) + "x" + String(shot.height) + "."
271
+ }, {
272
+ type: "image",
273
+ attachment: record.attachment
274
+ }];
275
+ }
276
+ },
277
+ async execute(args) {
278
+ const containerId = containerIdOf(args);
279
+ const raw = await withSession(containerId, "debug_capture_screenshot", {
280
+ format: "png",
281
+ fullPage: asRecord(args).fullPage === true
282
+ });
283
+ const data = Buffer.from(base64Of(raw), "base64");
284
+ return {
285
+ containerId,
286
+ attachment: await hostServices(ctx).attachments.saveImage({
287
+ data: new Uint8Array(data),
288
+ mediaType: "image/png"
289
+ })
290
+ };
291
+ }
292
+ });
293
+ registerTool(ctx, {
294
+ name: "box_click_element",
295
+ description: "Click a control on a running dshbox container page, at its centre, and report whether the click landed. Identify the target either by the role and name box_page_text returned, or by a CSS selector. Prefer role and name: a name is already in hand from the listing, while a selector has to be guessed and a guessed selector that happens to match hits the wrong control and still reports success. Add within when the same name appears both in a dialog and on the page behind it. Prefer this over box_click_at, which needs you to compute a point yourself.",
296
+ parameters: {
297
+ type: "object",
298
+ properties: {
299
+ containerId: {
300
+ type: "string",
301
+ description: "Container id to click in."
302
+ },
303
+ name: {
304
+ type: "string",
305
+ description: "Accessible name of the control, matched exactly, e.g. the one box_page_text reported."
306
+ },
307
+ role: {
308
+ type: "string",
309
+ description: "Role to require alongside the name, e.g. button or textbox."
310
+ },
311
+ within: {
312
+ type: "string",
313
+ description: "Container name to scope the search to, when the same name appears in more than one place."
314
+ },
315
+ selector: {
316
+ type: "string",
317
+ description: "CSS selector instead of a name, e.g. button.submit or [role=button]."
318
+ }
319
+ },
320
+ required: ["containerId"]
321
+ },
322
+ timeoutMs: WARM_BUDGET_MS,
323
+ output: {
324
+ schema: {
325
+ type: "object",
326
+ additionalProperties: false,
327
+ properties: {
328
+ containerId: { type: "string" },
329
+ name: { type: "string" },
330
+ matched: { type: "string" },
331
+ landed: { type: "boolean" },
332
+ hitTag: { type: "string" },
333
+ hitText: { type: "string" },
334
+ occludedBy: { type: "string" },
335
+ selector: { type: "string" },
336
+ x: { type: "number" },
337
+ y: { type: "number" },
338
+ tag: { type: "string" },
339
+ text: { type: "string" }
340
+ },
341
+ required: [
342
+ "containerId",
343
+ "x",
344
+ "y",
345
+ "landed"
346
+ ]
347
+ },
348
+ render: (_args, value) => {
349
+ const record = asRecord(value);
350
+ const label = textOf(record.text);
351
+ return renderClick(value, (typeof record.name === "string" && record.name !== "" ? JSON.stringify(record.name) : "<" + String(record.tag) + "> matching " + String(record.selector)) + (label ? " labelled " + JSON.stringify(label) : ""));
352
+ }
353
+ },
354
+ async execute(args) {
355
+ const containerId = containerIdOf(args);
356
+ const record = asRecord(args);
357
+ const name = record.name;
358
+ const selector = record.selector;
359
+ const hasName = typeof name === "string" && name.trim() !== "";
360
+ const hasSelector = typeof selector === "string" && selector.trim() !== "";
361
+ if (!hasName && !hasSelector) throw new Error("give a name, a selector, or both");
362
+ if (hasName) return {
363
+ containerId,
364
+ ...await withSession(containerId, "debug_click_by_name", {
365
+ name,
366
+ role: typeof record.role === "string" ? record.role : void 0,
367
+ within: typeof record.within === "string" ? record.within : void 0
368
+ })
369
+ };
370
+ return {
371
+ containerId,
372
+ ...await withSession(containerId, "debug_click_element", { selector })
373
+ };
374
+ }
375
+ });
376
+ registerTool(ctx, {
377
+ name: "box_click_at",
378
+ description: "Click a viewport point. Use it only when the target has no accessible name: coordinates come from box_page_text or from box_screenshot. Prefer box_click_element whenever a name was listed.",
379
+ parameters: {
380
+ type: "object",
381
+ properties: {
382
+ containerId: {
383
+ type: "string",
384
+ description: "Container id to click in."
385
+ },
386
+ x: {
387
+ type: "number",
388
+ description: "Horizontal viewport coordinate, from the left edge."
389
+ },
390
+ y: {
391
+ type: "number",
392
+ description: "Vertical viewport coordinate, from the top edge."
393
+ }
394
+ },
395
+ required: [
396
+ "containerId",
397
+ "x",
398
+ "y"
399
+ ]
400
+ },
401
+ timeoutMs: WARM_BUDGET_MS,
402
+ output: {
403
+ schema: {
404
+ type: "object",
405
+ additionalProperties: false,
406
+ properties: {
407
+ containerId: { type: "string" },
408
+ x: { type: "number" },
409
+ y: { type: "number" }
410
+ },
411
+ required: [
412
+ "containerId",
413
+ "x",
414
+ "y"
415
+ ]
416
+ },
417
+ render: (_args, value) => renderClick(value, "at (" + String(asRecord(value).x) + ", " + String(asRecord(value).y) + ")")
418
+ },
419
+ async execute(args) {
420
+ const containerId = containerIdOf(args);
421
+ const record = asRecord(args);
422
+ if (typeof record.x !== "number" || typeof record.y !== "number") throw new Error("x and y are required numbers");
423
+ return {
424
+ containerId,
425
+ ...await withSession(containerId, "debug_click_at", {
426
+ x: record.x,
427
+ y: record.y
428
+ })
429
+ };
430
+ }
431
+ });
432
+ registerTool(ctx, {
433
+ name: "box_page_text",
434
+ description: "CALL THIS FIRST when you need to know what is on a page. It renders the whole page as text: every control and region with its role, name, on-screen position, and whether it is currently visible, and whether it is disabled. It needs no selector, and it is the only listing: there is no second way to ask what is on a page. Then hand the role+name it returns straight back to box_click_element. Anything marked BELOW FOLD needs box_scroll before you can act on it.",
435
+ parameters: {
436
+ type: "object",
437
+ properties: {
438
+ containerId: {
439
+ type: "string",
440
+ description: "Container id to read."
441
+ },
442
+ role: {
443
+ type: "string",
444
+ description: "Only roles containing this text, e.g. button or textbox. Omit for everything."
445
+ },
446
+ within: {
447
+ type: "string",
448
+ description: "NaNname is ambiguous: a settings dialog and the chat screen behind it canboth offer the same one, and acting on the wrong element still looks likeit worked."
449
+ },
450
+ limit: {
451
+ type: "number",
452
+ description: "Maximum entries to return. Defaults to 200."
453
+ }
454
+ },
455
+ required: ["containerId"]
456
+ },
457
+ timeoutMs: WARM_BUDGET_MS,
458
+ output: {
459
+ schema: {
460
+ type: "object",
461
+ additionalProperties: false,
462
+ properties: {
463
+ containerId: { type: "string" },
464
+ count: { type: "number" },
465
+ truncated: { type: "boolean" },
466
+ belowFold: { type: "number" },
467
+ elements: {
468
+ type: "array",
469
+ items: { type: "object" }
470
+ }
471
+ },
472
+ required: [
473
+ "containerId",
474
+ "count",
475
+ "truncated",
476
+ "belowFold",
477
+ "elements"
478
+ ]
479
+ },
480
+ render: (_args, value) => {
481
+ const record = asRecord(value);
482
+ const elements = Array.isArray(record.elements) ? record.elements : [];
483
+ const below = Number(record.belowFold ?? 0);
484
+ const lines = [];
485
+ lines.push("Page of container " + String(record.containerId) + ": " + String(record.count) + " entr" + (record.count === 1 ? "y" : "ies") + (record.truncated === true ? " (truncated at the limit)." : "."));
486
+ if (below > 0) lines.push(below + " entr" + (below === 1 ? "y is" : "ies are") + " below the fold; call box_scroll to reach them.");
487
+ lines.push(renderPageElements(elements));
488
+ lines.push(nextHint("act on an entry with box_click_element (role+name) or box_click_at (x,y)"));
489
+ return [{
490
+ type: "text",
491
+ text: lines.join("\n")
492
+ }];
493
+ }
494
+ },
495
+ async execute(args) {
496
+ const containerId = containerIdOf(args);
497
+ const record = asRecord(args);
498
+ return {
499
+ containerId,
500
+ ...await withSession(containerId, "debug_page_text", {
501
+ role: typeof record.role === "string" ? record.role : void 0,
502
+ limit: typeof record.limit === "number" ? record.limit : void 0,
503
+ within: typeof record.within === "string" ? record.within : void 0
504
+ })
505
+ };
506
+ }
507
+ });
508
+ registerTool(ctx, {
509
+ name: "box_scroll",
510
+ description: "How far a page extends, and optionally scroll it. With no coordinates it reports how many screens are below the fold, which is how you learn a control exists but is not reachable yet; an absolute pixel offset moves. Coordinates are viewport pixels, so a listed position is used as-is.",
511
+ parameters: {
512
+ type: "object",
513
+ properties: {
514
+ containerId: {
515
+ type: "string",
516
+ description: "Container id to scroll."
517
+ },
518
+ to: {
519
+ type: "number",
520
+ description: "Absolute scroll offset in pixels. Omit to only report the extent."
521
+ }
522
+ },
523
+ required: ["containerId"]
524
+ },
525
+ timeoutMs: WARM_BUDGET_MS,
526
+ output: {
527
+ schema: {
528
+ type: "object",
529
+ additionalProperties: false,
530
+ properties: {
531
+ containerId: { type: "string" },
532
+ scrollTop: { type: "number" },
533
+ scrollHeight: { type: "number" },
534
+ viewportHeight: { type: "number" },
535
+ screensBelow: { type: "number" },
536
+ moved: { type: "boolean" }
537
+ },
538
+ required: [
539
+ "containerId",
540
+ "scrollTop",
541
+ "scrollHeight",
542
+ "viewportHeight",
543
+ "screensBelow",
544
+ "moved"
545
+ ]
546
+ },
547
+ render: (_args, value) => {
548
+ const record = asRecord(value);
549
+ const below = Number(record.screensBelow ?? 0);
550
+ return [{
551
+ type: "text",
552
+ text: "Scroll of container " + String(record.containerId) + ": at " + String(record.scrollTop) + " of " + String(record.scrollHeight) + " px, viewport " + String(record.viewportHeight) + " px." + (record.moved === true ? " Moved." : "") + (below > 0 ? " " + below + " screen(s) still below." : " Nothing below the fold.") + nextHint("call box_page_text again to see the newly reachable entries")
553
+ }];
554
+ }
555
+ },
556
+ async execute(args) {
557
+ const containerId = containerIdOf(args);
558
+ const record = asRecord(args);
559
+ return {
560
+ containerId,
561
+ ...await withSession(containerId, "debug_scroll", { to: typeof record.to === "number" ? record.to : void 0 })
562
+ };
563
+ }
564
+ });
565
+ registerTool(ctx, {
566
+ name: "box_type_text",
567
+ description: "Type into whatever has focus. Click the field with box_click_element first. It reports which element held focus, so a value that landed nowhere reads as a failure rather than a silent no-op. For a submit use box_press_key with Enter.",
568
+ parameters: {
569
+ type: "object",
570
+ properties: {
571
+ containerId: {
572
+ type: "string",
573
+ description: "Container id to type into."
574
+ },
575
+ text: {
576
+ type: "string",
577
+ description: "Text to insert at the focused field."
578
+ }
579
+ },
580
+ required: ["containerId", "text"]
581
+ },
582
+ timeoutMs: WARM_BUDGET_MS,
583
+ output: {
584
+ schema: {
585
+ type: "object",
586
+ additionalProperties: false,
587
+ properties: {
588
+ containerId: { type: "string" },
589
+ inserted: { type: "number" },
590
+ focused: { type: "string" },
591
+ landed: { type: "boolean" }
592
+ },
593
+ required: [
594
+ "containerId",
595
+ "inserted",
596
+ "landed"
597
+ ]
598
+ },
599
+ render: (_args, value) => {
600
+ const record = asRecord(value);
601
+ return [{
602
+ type: "text",
603
+ text: record.landed === true ? "Typed " + String(record.inserted) + " character(s) into <" + String(record.focused ?? "input") + "> in container " + String(record.containerId) + "." + nextHint("press Enter with box_press_key to submit, or click the submit control") : "Nothing was focused, so the " + String(record.inserted) + " character(s) went nowhere." + nextHint("click the field with box_click_element first, then type again")
604
+ }];
605
+ }
606
+ },
607
+ async execute(args) {
608
+ const containerId = containerIdOf(args);
609
+ const record = asRecord(args);
610
+ if (typeof record.text !== "string" || record.text === "") throw new Error("text is required and must be non-empty");
611
+ return {
612
+ containerId,
613
+ ...await withSession(containerId, "debug_type_text", { text: record.text })
614
+ };
615
+ }
616
+ });
617
+ registerTool(ctx, {
618
+ name: "box_press_key",
619
+ description: "Press and release one named key. Enter submits a form and nothing that is not a form, so when a click on submit reports landed:false, try Enter. Names: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, Shift, Control, Alt. An unknown name is an error, never a guess.",
620
+ parameters: {
621
+ type: "object",
622
+ properties: {
623
+ containerId: {
624
+ type: "string",
625
+ description: "Container id to press in."
626
+ },
627
+ key: {
628
+ type: "string",
629
+ description: "Key name, e.g. Enter or PageDown."
630
+ }
631
+ },
632
+ required: ["containerId", "key"]
633
+ },
634
+ timeoutMs: WARM_BUDGET_MS,
635
+ output: {
636
+ schema: {
637
+ type: "object",
638
+ additionalProperties: false,
639
+ properties: {
640
+ containerId: { type: "string" },
641
+ key: { type: "string" }
642
+ },
643
+ required: ["containerId", "key"]
644
+ },
645
+ render: (_args, value) => {
646
+ const record = asRecord(value);
647
+ return [{
648
+ type: "text",
649
+ text: "Pressed " + String(record.key) + " in container " + String(record.containerId) + "." + nextHint("call box_page_text to see what the page looks like now")
650
+ }];
651
+ }
652
+ },
653
+ async execute(args) {
654
+ const containerId = containerIdOf(args);
655
+ const record = asRecord(args);
656
+ if (typeof record.key !== "string" || record.key.trim() === "") throw new Error("key is required");
657
+ return {
658
+ containerId,
659
+ ...await withSession(containerId, "debug_press_key", { key: record.key.trim() })
660
+ };
661
+ }
662
+ });
663
+ registerTool(ctx, {
664
+ name: "box_set_browser",
665
+ description: "Choose which browser the other box_* tools drive, and report the one in use. Call it when a page tool fails to launch a browser, or when auto-detection picks a browser you would rather not use. Pass an absolute path to a Chrome or Edge executable to pin it; pass an empty string to go back to auto-detection. The path is validated before it is saved, so a typo is rejected here rather than surfacing later as a failed launch.",
666
+ parameters: {
667
+ type: "object",
668
+ properties: { path: {
669
+ type: "string",
670
+ description: "Absolute path to a browser executable, or an empty string to return to auto-detection."
671
+ } },
672
+ required: []
673
+ },
674
+ timeoutMs: WARM_BUDGET_MS,
675
+ output: {
676
+ schema: {
677
+ type: "object",
678
+ additionalProperties: false,
679
+ properties: {
680
+ available: { type: "boolean" },
681
+ kind: { type: "string" },
682
+ path: { type: "string" },
683
+ configured: { type: "boolean" },
684
+ configuredPath: { type: "string" },
685
+ problem: { type: "string" }
686
+ },
687
+ required: ["available"]
688
+ },
689
+ render: (_args, value) => {
690
+ const record = asRecord(value);
691
+ if (record.available === false) return [{
692
+ type: "text",
693
+ text: "No browser is available: " + String(record.problem ?? "unknown reason") + (record.configuredPath ? " (configured: " + String(record.configuredPath) + ")" : "") + nextHint("pass a browser executable path to box_set_browser, then retry the page tool")
694
+ }];
695
+ const pinned = record.configured === true;
696
+ return [{
697
+ type: "text",
698
+ text: "Using " + String(record.kind ?? "browser") + " at " + String(record.path ?? "unknown") + (pinned ? " (pinned by you)." : " (auto-detected).") + nextHint("call box_page_text to see what the page looks like now")
699
+ }];
700
+ }
701
+ },
702
+ async execute(args) {
703
+ const record = asRecord(args);
704
+ const path = typeof record.path === "string" ? record.path : "";
705
+ return path === "" && record.path === void 0 ? getRpc().call("debug_browser_status", {}) : getRpc().call("debug_set_browser_path", { path });
706
+ }
707
+ });
708
+ registerTool(ctx, {
709
+ name: "box_close",
710
+ description: "Release the headless browser. For tidying up after a long debugging session, not for making something work: sessions also close on their own, and the next page tool opens a fresh one.",
711
+ parameters: {
712
+ type: "object",
713
+ properties: { containerId: {
714
+ type: "string",
715
+ description: "Container whose browser session to close."
716
+ } },
717
+ required: ["containerId"]
718
+ },
719
+ timeoutMs: WARM_BUDGET_MS,
720
+ output: {
721
+ schema: {
722
+ type: "object",
723
+ additionalProperties: false,
724
+ properties: {
725
+ containerId: { type: "string" },
726
+ closed: { type: "boolean" }
727
+ },
728
+ required: ["containerId", "closed"]
729
+ },
730
+ render: (_args, value) => {
731
+ const record = asRecord(value);
732
+ return [{
733
+ type: "text",
734
+ text: (record.closed === true ? "Closed" : "There was no open session to close for") + " the browser of container " + String(record.containerId) + "."
735
+ }];
736
+ }
737
+ },
738
+ async execute(args) {
739
+ const containerId = containerIdOf(args);
740
+ return {
741
+ containerId,
742
+ ...await getRpc().call("debug_close", { id: containerId })
743
+ };
744
+ }
745
+ });
746
+ }
747
+ //#endregion
748
+ //#region src/manage.ts
749
+ /** A long operation; a container start can sit on a prepare for minutes. */
750
+ const LONG_BUDGET_MS = 9e5;
751
+ /** Read a value that must be a non-empty string, or fail naming the field. */
752
+ function required(args, field) {
753
+ const value = args[field];
754
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} is required and must be a non-empty string.`);
755
+ return value.trim();
756
+ }
757
+ /** Read an optional string, treating blank as absent. */
758
+ function optional(args, field) {
759
+ const value = args[field];
760
+ if (typeof value !== "string") return void 0;
761
+ const trimmed = value.trim();
762
+ return trimmed === "" ? void 0 : trimmed;
763
+ }
764
+ /** Read an optional boolean that only an explicit `true` turns on. */
765
+ function flag(args, field) {
766
+ return args[field] === true;
767
+ }
768
+ /** Reject an action a tool does not implement, listing the ones it does. */
769
+ function badAction(tool, given, allowed) {
770
+ throw new Error(`${String(given)} is not a ${tool} action. Use one of: ${allowed.join(", ")}.`);
771
+ }
772
+ /** Shared shape for the JSON Schema of a single-property action. */
773
+ function actionProperty(tool, allowed) {
774
+ return {
775
+ type: "string",
776
+ enum: [...allowed],
777
+ description: `What to do. One of: ${allowed.join(", ")} (${tool}).`
778
+ };
779
+ }
780
+ /** A text-only result with a schema that names what the caller can read. */
781
+ function textOutput(properties, required, render) {
782
+ return {
783
+ schema: {
784
+ type: "object",
785
+ additionalProperties: false,
786
+ properties,
787
+ required
788
+ },
789
+ render: (_args, value) => [{
790
+ type: "text",
791
+ text: render(value)
792
+ }]
793
+ };
794
+ }
795
+ /** Register one tool against the host's tools service, the same path the
796
+ * page-debugging tools take, so both halves live in one namespace.
797
+ */
798
+ function register(ctx, definition) {
799
+ hostServices(ctx).tools.register(definition);
800
+ }
801
+ /** Register the dshbox management tools. */
802
+ function registerManageTools(ctx) {
803
+ register(ctx, {
804
+ name: "box_lifecycle",
805
+ description: "Start, stop, restart, rebuild or remove a dshbox container, and wait for it to finish. This is the tool for anything that changes whether a container runs. It blocks until the operation completes and returns the outcome, so a restart that failed comes back as a failure with the reason rather than a task id and silence. Use it instead of running dshbox on a shell: a start that quietly did not start is the failure this removes. Rebuild re-materialises plugins and resources before starting; restart does not. After a start, box_overview reports the new state. Every action is refused when the target is the container this agent is itself running in, because it restarts or stops the host running the call and the result can never come back. Watch progress with box_task and confirm with box_overview instead.",
806
+ parameters: {
807
+ type: "object",
808
+ properties: {
809
+ action: actionProperty("lifecycle", [
810
+ "start",
811
+ "stop",
812
+ "stop-now",
813
+ "restart",
814
+ "rebuild",
815
+ "remove"
816
+ ]),
817
+ containerId: {
818
+ type: "string",
819
+ description: "Container to act on. box_overview lists the ids."
820
+ }
821
+ },
822
+ required: ["action", "containerId"]
823
+ },
824
+ timeoutMs: LONG_BUDGET_MS,
825
+ output: textOutput({
826
+ action: { type: "string" },
827
+ containerId: { type: "string" },
828
+ kind: { type: "string" }
829
+ }, [
830
+ "action",
831
+ "containerId",
832
+ "kind"
833
+ ], (value) => `Container ${String(value.containerId)}: ${String(value.action)} finished as ${String(value.kind)}.`),
834
+ async execute(args) {
835
+ const record = args;
836
+ const containerId = required(record, "containerId");
837
+ const action = required(record, "action");
838
+ const rpc = getRpc();
839
+ const methods = {
840
+ start: "enqueue_container_start",
841
+ stop: "enqueue_container_stop",
842
+ restart: "enqueue_container_restart",
843
+ rebuild: "enqueue_container_rebuild",
844
+ remove: "delete_container"
845
+ };
846
+ if (action === "stop-now") {
847
+ if (runsInside(containerId)) throw new Error("This agent is running inside " + containerId + ", so stopping it would end the call before it could report a result.");
848
+ return {
849
+ action,
850
+ containerId,
851
+ kind: await rpc.call("stop_container", { id: containerId })
852
+ };
853
+ }
854
+ const method = methods[action];
855
+ if (method === void 0) badAction("lifecycle", action, Object.keys(methods));
856
+ if (runsInside(containerId)) throw new Error(`This agent is running inside ${containerId}, and ${action} would restart or stop the host running this call, so the result could never be reported -- the session reconnects and the call is simply lost. Cycle the container from outside it (the Box UI, or a dshbox command in a separate terminal), then call box_task to watch the work, or box_overview to confirm the state.`);
857
+ return {
858
+ action,
859
+ containerId,
860
+ kind: (await runTask(method, { id: containerId })).kind
861
+ };
862
+ }
863
+ });
864
+ register(ctx, {
865
+ name: "box_overview",
866
+ description: "What dshbox currently holds: its containers, its templates and the plugins it knows. Call this first when you do not know what exists, and after any change that could have added or removed something. It answers from the store, so the container ids it prints are the ones box_lifecycle and box_resources accept.",
867
+ parameters: {
868
+ type: "object",
869
+ properties: { include: {
870
+ type: "string",
871
+ description: "Comma-separated: box, containers, templates, plugins. Defaults to all four."
872
+ } },
873
+ required: []
874
+ },
875
+ timeoutMs: 6e4,
876
+ output: textOutput({
877
+ box: { type: "object" },
878
+ containers: {
879
+ type: "array",
880
+ items: { type: "object" }
881
+ },
882
+ templates: {
883
+ type: "array",
884
+ items: { type: "object" }
885
+ },
886
+ plugins: {
887
+ type: "array",
888
+ items: { type: "object" }
889
+ }
890
+ }, [
891
+ "box",
892
+ "containers",
893
+ "templates",
894
+ "plugins"
895
+ ], (value) => {
896
+ const lines = [];
897
+ const box = value.box ?? {};
898
+ const containers = value.containers ?? [];
899
+ const templates = value.templates ?? [];
900
+ const plugins = value.plugins ?? [];
901
+ const stamp = box.buildStamp !== void 0 ? ` (build ${String(box.buildStamp)})` : "";
902
+ lines.push(`Box${stamp}: ` + Object.entries(box).filter(([key]) => key !== "buildStamp").map(([key, entry]) => `${key}=${typeof entry === "object" ? JSON.stringify(entry) : String(entry)}`).join(" "));
903
+ lines.push(`Containers (${containers.length}):`, ...containers.map((row) => ` ${String(row.id)} ${String(row.name ?? "")} ${String(row.status ?? "")}`));
904
+ lines.push(`Templates (${templates.length}):`, ...templates.map((row) => ` ${String(row.name)} ${String(row.kind ?? "")}`));
905
+ lines.push(`Plugins (${plugins.length}):`, ...plugins.map((row) => ` ${String(row.name ?? row.id)}`));
906
+ return lines.join("\n");
907
+ }),
908
+ async execute(args) {
909
+ const want = (optional(args, "include") ?? "box,containers,templates,plugins").split(",").map((part) => part.trim()).filter((part) => part !== "");
910
+ const rpc = getRpc();
911
+ const result = {
912
+ box: {},
913
+ containers: [],
914
+ templates: [],
915
+ plugins: []
916
+ };
917
+ if (want.includes("box")) result.box = asObject(await rpc.call("get_info"));
918
+ if (want.includes("containers")) result.containers = asArray(await rpc.call("list_containers"));
919
+ if (want.includes("templates")) result.templates = asArray(await rpc.call("list_templates"));
920
+ if (want.includes("plugins")) result.plugins = asArray(await rpc.call("list_repository_extensions"));
921
+ return result;
922
+ }
923
+ });
924
+ register(ctx, {
925
+ name: "box_resources",
926
+ description: "The state a container persists, and the tools to move it: list what is there, read one file as a YAML tree, write a block, or extract and inject it as a reusable resource. Everything that changes a container waits for the operation to finish and reports the result, so an injection that was refused arrives as a refusal. Reading is the cheapest way to see what a container actually has, including provider credentials -- a secret's existence and size, never its contents.",
927
+ parameters: {
928
+ type: "object",
929
+ properties: {
930
+ action: actionProperty("resources", [
931
+ "list",
932
+ "read",
933
+ "write",
934
+ "extract",
935
+ "inject",
936
+ "rm",
937
+ "rm-view",
938
+ "prune",
939
+ "types",
940
+ "views"
941
+ ]),
942
+ containerId: {
943
+ type: "string",
944
+ description: "Container to act on."
945
+ },
946
+ path: {
947
+ type: "string",
948
+ description: "Container-relative file. Required for read and write, and names the resource kind's default location for extract."
949
+ },
950
+ kind: {
951
+ type: "string",
952
+ description: "Resource kind, e.g. sessions or credentials. Required by action types."
953
+ },
954
+ section: {
955
+ type: "string",
956
+ description: "YAML key path to read or write at, e.g. llm-pi-ai. Empty means the whole file."
957
+ },
958
+ text: {
959
+ type: "string",
960
+ description: "The YAML block to place at --section."
961
+ },
962
+ resourceId: {
963
+ type: "string",
964
+ description: "Which stored resource to inject or remove."
965
+ },
966
+ viewId: {
967
+ type: "string",
968
+ description: "Which resource view to remove."
969
+ },
970
+ name: {
971
+ type: "string",
972
+ description: "Name for an extracted resource."
973
+ },
974
+ merge: {
975
+ type: "boolean",
976
+ description: "Merge into an existing destination instead of refusing."
977
+ },
978
+ overwrite: {
979
+ type: "boolean",
980
+ description: "Replace the destination first."
981
+ },
982
+ restart: {
983
+ type: "boolean",
984
+ description: "Stop a running container, act, then start it again."
985
+ }
986
+ },
987
+ required: ["action", "containerId"]
988
+ },
989
+ timeoutMs: LONG_BUDGET_MS,
990
+ output: textOutput({
991
+ action: { type: "string" },
992
+ containerId: { type: "string" },
993
+ result: { type: "object" }
994
+ }, [
995
+ "action",
996
+ "containerId",
997
+ "result"
998
+ ], (value) => {
999
+ const payload = value.result;
1000
+ return `Container ${String(value.containerId)}: ${String(value.action)}.\n` + JSON.stringify(payload, null, 2).slice(0, 6e3);
1001
+ }),
1002
+ async execute(args) {
1003
+ const record = args;
1004
+ const containerId = required(record, "containerId");
1005
+ const action = required(record, "action");
1006
+ const rpc = getRpc();
1007
+ const base = { id: containerId };
1008
+ const carry = (extra) => {
1009
+ const params = {
1010
+ ...base,
1011
+ ...extra
1012
+ };
1013
+ if (flag(record, "merge")) params.merge = true;
1014
+ if (flag(record, "overwrite")) params.overwrite = true;
1015
+ if (flag(record, "restart")) params.restart = true;
1016
+ return params;
1017
+ };
1018
+ switch (action) {
1019
+ case "list": {
1020
+ const request = { ...base };
1021
+ const kind = optional(record, "kind");
1022
+ if (kind !== void 0) request.plugin = kind;
1023
+ return {
1024
+ action,
1025
+ containerId,
1026
+ result: await rpc.call("list_container_resources", request)
1027
+ };
1028
+ }
1029
+ case "read": return {
1030
+ action,
1031
+ containerId,
1032
+ result: await rpc.call("read_resource_tree", carry({
1033
+ path: required(record, "path"),
1034
+ section: optional(record, "section") ?? ""
1035
+ }))
1036
+ };
1037
+ case "write": {
1038
+ const task = await runTask("enqueue_resource_write", carry({
1039
+ path: required(record, "path"),
1040
+ section: optional(record, "section") ?? "",
1041
+ text: required(record, "text")
1042
+ }));
1043
+ return {
1044
+ action,
1045
+ containerId,
1046
+ result: {
1047
+ kind: task.kind,
1048
+ logPath: task.logPath ?? ""
1049
+ }
1050
+ };
1051
+ }
1052
+ case "extract": {
1053
+ const task = await runTask("enqueue_resource_extract", carry({
1054
+ kind: required(record, "kind"),
1055
+ name: optional(record, "name"),
1056
+ path: optional(record, "path")
1057
+ }));
1058
+ return {
1059
+ action,
1060
+ containerId,
1061
+ result: {
1062
+ kind: task.kind,
1063
+ logPath: task.logPath ?? ""
1064
+ }
1065
+ };
1066
+ }
1067
+ case "inject": {
1068
+ const task = await runTask("enqueue_resource_inject", carry({ resourceId: required(record, "resourceId") }));
1069
+ return {
1070
+ action,
1071
+ containerId,
1072
+ result: {
1073
+ kind: task.kind,
1074
+ logPath: task.logPath ?? ""
1075
+ }
1076
+ };
1077
+ }
1078
+ case "rm": return {
1079
+ action,
1080
+ containerId,
1081
+ result: asObject(await rpc.call("delete_resource", { resourceId: required(record, "resourceId") }))
1082
+ };
1083
+ case "rm-view": return {
1084
+ action,
1085
+ containerId,
1086
+ result: asObject(await rpc.call("delete_resource_view", { id: required(record, "viewId") }))
1087
+ };
1088
+ case "prune": return {
1089
+ action,
1090
+ containerId,
1091
+ result: { removed: await rpc.call("prune_orphaned_data") }
1092
+ };
1093
+ case "types": {
1094
+ const request = {
1095
+ id: containerId,
1096
+ kind: required(record, "kind")
1097
+ };
1098
+ return {
1099
+ action,
1100
+ containerId,
1101
+ result: { types: await rpc.call("list_resource_type", request) }
1102
+ };
1103
+ }
1104
+ case "views": {
1105
+ const request = { id: containerId };
1106
+ const kind = optional(record, "kind");
1107
+ if (kind !== void 0) request.kind = kind;
1108
+ return {
1109
+ action,
1110
+ containerId,
1111
+ result: { views: await rpc.call("list_resource_views", request) }
1112
+ };
1113
+ }
1114
+ default: badAction("resources", action, [
1115
+ "list",
1116
+ "read",
1117
+ "write",
1118
+ "extract",
1119
+ "inject",
1120
+ "rm",
1121
+ "rm-view",
1122
+ "prune",
1123
+ "types",
1124
+ "views"
1125
+ ]);
1126
+ }
1127
+ }
1128
+ });
1129
+ register(ctx, {
1130
+ name: "box_build",
1131
+ description: "Build a template from a boxfile, and wait for the build to finish. A template is what you then create a container from. The path is read from the host, so give an absolute one. Applying a resource document is deliberately not here: the daemon has no single method for it, it is a sequence the CLI drives, and re-implementing that sequence in a tool is how two copies drift apart. Use box_resources for moving state.",
1132
+ parameters: {
1133
+ type: "object",
1134
+ properties: {
1135
+ mode: {
1136
+ type: "string",
1137
+ enum: ["build", "apply"],
1138
+ description: "`build` compiles a boxfile into a template. `apply` makes a resource layer match a document."
1139
+ },
1140
+ file: {
1141
+ type: "string",
1142
+ description: "Absolute path to the boxfile, or to the apply document."
1143
+ },
1144
+ document: {
1145
+ type: "string",
1146
+ description: "An apply document as YAML or JSON, given inline instead of a file."
1147
+ },
1148
+ containerId: {
1149
+ type: "string",
1150
+ description: "Which container to apply to. Not used by build."
1151
+ },
1152
+ dryRun: {
1153
+ type: "boolean",
1154
+ description: "Report what apply would change without changing it."
1155
+ }
1156
+ },
1157
+ required: ["mode"]
1158
+ },
1159
+ timeoutMs: LONG_BUDGET_MS,
1160
+ output: textOutput({
1161
+ mode: { type: "string" },
1162
+ file: { type: "string" },
1163
+ result: { type: "object" }
1164
+ }, [
1165
+ "mode",
1166
+ "file",
1167
+ "result"
1168
+ ], (value) => `${String(value.mode)} ${String(value.file)}:\n` + JSON.stringify(value.result, null, 2).slice(0, 4e3)),
1169
+ async execute(args) {
1170
+ const record = args;
1171
+ const mode = required(record, "mode");
1172
+ const file = optional(record, "file") ?? "";
1173
+ if (mode === "build") {
1174
+ const task = await runTask("enqueue_build", {
1175
+ path: file,
1176
+ name: optional(record, "containerId")
1177
+ });
1178
+ return {
1179
+ mode,
1180
+ file,
1181
+ result: {
1182
+ kind: task.kind,
1183
+ logPath: task.logPath ?? ""
1184
+ }
1185
+ };
1186
+ }
1187
+ if (mode === "apply") {
1188
+ const rpc = getRpc();
1189
+ const params = { dryRun: flag(record, "dryRun") };
1190
+ const document = optional(record, "document");
1191
+ if (document !== void 0) params.document = document;
1192
+ if (file !== "") params.file = file;
1193
+ return {
1194
+ mode,
1195
+ file,
1196
+ result: await rpc.call("apply_document", params)
1197
+ };
1198
+ }
1199
+ badAction("build", mode, ["build", "apply"]);
1200
+ }
1201
+ });
1202
+ register(ctx, {
1203
+ name: "box_workspace",
1204
+ description: "The directories a container opens its sessions in. add registers one from a path with no file dialog, which is the point: the picker the UI uses opens on the host desktop and nothing driving the app from a script or a tool call can answer it. Add refuses while the container runs, because the running host rewrites its own registry; pass restart to stop, register and start again in that order. Adding a path that is already registered reports it as such rather than failing.",
1205
+ parameters: {
1206
+ type: "object",
1207
+ properties: {
1208
+ action: actionProperty("workspace", ["list", "add"]),
1209
+ containerId: {
1210
+ type: "string",
1211
+ description: "Container whose workspaces to list or extend."
1212
+ },
1213
+ path: {
1214
+ type: "string",
1215
+ description: "Absolute path of an existing directory. Required for add."
1216
+ },
1217
+ title: {
1218
+ type: "string",
1219
+ description: "Name for it. Defaults to the directory's own name."
1220
+ },
1221
+ restart: {
1222
+ type: "boolean",
1223
+ description: "Stop the container, add, then start it again."
1224
+ }
1225
+ },
1226
+ required: ["action", "containerId"]
1227
+ },
1228
+ timeoutMs: LONG_BUDGET_MS,
1229
+ output: textOutput({
1230
+ action: { type: "string" },
1231
+ containerId: { type: "string" },
1232
+ workspaces: {
1233
+ type: "array",
1234
+ items: { type: "object" }
1235
+ },
1236
+ created: { type: "boolean" }
1237
+ }, [
1238
+ "action",
1239
+ "containerId",
1240
+ "workspaces"
1241
+ ], (value) => {
1242
+ const rows = value.workspaces ?? [];
1243
+ return [`Workspaces of ${String(value.containerId)} (${rows.length}):`, ...rows.map((row) => ` ${String(row.title)} ${String(row.path)} ${String(row.sessions ?? 0)} sessions`)].join("\n");
1244
+ }),
1245
+ async execute(args) {
1246
+ const record = args;
1247
+ const containerId = required(record, "containerId");
1248
+ const action = required(record, "action");
1249
+ const rpc = getRpc();
1250
+ if (action === "list") return {
1251
+ action,
1252
+ containerId,
1253
+ workspaces: (await rpc.call("list_container_workspaces", { id: containerId }))?.workspaces ?? [],
1254
+ created: false
1255
+ };
1256
+ if (action === "add") {
1257
+ const params = {
1258
+ id: containerId,
1259
+ path: required(record, "path")
1260
+ };
1261
+ const title = optional(record, "title");
1262
+ if (title !== void 0) params.title = title;
1263
+ const added = await rpc.call("add_container_workspace", params);
1264
+ return {
1265
+ action,
1266
+ containerId,
1267
+ workspaces: (await rpc.call("list_container_workspaces", { id: containerId })).workspaces ?? [],
1268
+ created: added?.created === true
1269
+ };
1270
+ }
1271
+ badAction("workspace", action, ["list", "add"]);
1272
+ }
1273
+ });
1274
+ register(ctx, {
1275
+ name: "box_set_viewport",
1276
+ description: "Resize the page an agent is looking at, and report the size now in effect. The page opens at 1600x1200, which fits most applications on one screen, but a wide table or a tall form may still fold. A fold is not cosmetic: box_page_text marks anything below the fold, so a control that is in fact visible reads as something to scroll for, and box_click_element on it is a wasted round trip. Make it taller, or wider when a row is being cut off on the right, then read the page again. Resizing keeps the page where it is, so a session survives the change.",
1277
+ parameters: {
1278
+ type: "object",
1279
+ properties: {
1280
+ containerId: {
1281
+ type: "string",
1282
+ description: "Which debug session to resize."
1283
+ },
1284
+ width: {
1285
+ type: "number",
1286
+ description: "Width in CSS pixels. 320-7680. Omit to read the current size."
1287
+ },
1288
+ height: {
1289
+ type: "number",
1290
+ description: "Height in CSS pixels. 240-4320. Omit to read the current size."
1291
+ }
1292
+ },
1293
+ required: ["containerId"]
1294
+ },
1295
+ timeoutMs: 6e4,
1296
+ output: textOutput({
1297
+ width: { type: "number" },
1298
+ height: { type: "number" },
1299
+ clamped: { type: "boolean" }
1300
+ }, ["width", "height"], (value) => `Viewport is now ${String(value.width)}x${String(value.height)}` + (value.clamped === true ? ". That is smaller than it was asked for: the browser has a maximum." : ".")),
1301
+ async execute(args) {
1302
+ const record = args;
1303
+ const containerId = required(record, "containerId");
1304
+ const rpc = getRpc();
1305
+ const params = { id: containerId };
1306
+ const width = number(record, "width");
1307
+ const height = number(record, "height");
1308
+ if (width === void 0 && height === void 0) {
1309
+ const metrics = await rpc.call("debug_set_viewport", params);
1310
+ return {
1311
+ width: Number(metrics.width),
1312
+ height: Number(metrics.height),
1313
+ clamped: false
1314
+ };
1315
+ }
1316
+ if (width === void 0 || height === void 0) throw new Error("Give both width and height to resize the viewport.");
1317
+ const result = await rpc.call("debug_set_viewport", {
1318
+ ...params,
1319
+ width,
1320
+ height
1321
+ });
1322
+ return {
1323
+ width: Number(result.width),
1324
+ height: Number(result.height),
1325
+ clamped: result.clamped === true
1326
+ };
1327
+ }
1328
+ });
1329
+ register(ctx, {
1330
+ name: "box_task",
1331
+ description: "Watch, read or cancel dshbox background work. Most box_* calls already wait for their own operation, so reach for this when something is already running, when you want to see what a previous call left behind, or to stop work that should not continue. A task log is a file, and its path comes back with the record.",
1332
+ parameters: {
1333
+ type: "object",
1334
+ properties: {
1335
+ action: actionProperty("task", [
1336
+ "list",
1337
+ "status",
1338
+ "cancel",
1339
+ "delete"
1340
+ ]),
1341
+ taskId: {
1342
+ type: "string",
1343
+ description: "Which task. Required for status, cancel and delete."
1344
+ },
1345
+ kind: {
1346
+ type: "string",
1347
+ description: "Only list tasks of this kind, e.g. container-start. Omit for all."
1348
+ }
1349
+ },
1350
+ required: ["action"]
1351
+ },
1352
+ timeoutMs: 6e4,
1353
+ output: textOutput({
1354
+ action: { type: "string" },
1355
+ tasks: {
1356
+ type: "array",
1357
+ items: { type: "object" }
1358
+ }
1359
+ }, ["action", "tasks"], (value) => {
1360
+ const rows = value.tasks ?? [];
1361
+ return [`Tasks (${rows.length}):`, ...rows.map((row) => ` ${String(row.id)} ${String(row.status)} ${String(row.progress)}% ${String(row.kind)}` + (row.error ? ` error: ${String(row.error)}` : ""))].join("\n");
1362
+ }),
1363
+ async execute(args) {
1364
+ const record = args;
1365
+ const action = required(record, "action");
1366
+ const rpc = getRpc();
1367
+ const one = async () => {
1368
+ const task = await rpc.call("task_status", { id: required(record, "taskId") });
1369
+ return {
1370
+ action,
1371
+ tasks: [task]
1372
+ };
1373
+ };
1374
+ switch (action) {
1375
+ case "list": {
1376
+ const all = asArray(await rpc.call("list_tasks"));
1377
+ const kind = optional(record, "kind");
1378
+ return {
1379
+ action,
1380
+ tasks: kind === void 0 ? all : all.filter((row) => String(row.kind) === kind)
1381
+ };
1382
+ }
1383
+ case "status": return one();
1384
+ case "cancel": return {
1385
+ action,
1386
+ tasks: [await rpc.call("cancel_task", { id: required(record, "taskId") })]
1387
+ };
1388
+ case "delete":
1389
+ await rpc.call("delete_task", { id: required(record, "taskId") });
1390
+ return {
1391
+ action,
1392
+ tasks: []
1393
+ };
1394
+ default: badAction("task", action, [
1395
+ "list",
1396
+ "status",
1397
+ "cancel",
1398
+ "delete"
1399
+ ]);
1400
+ }
1401
+ }
1402
+ });
1403
+ /**
1404
+ * The arguments one action needs, checked here rather than at the call site.
1405
+ *
1406
+ * The parameter schema is a flat union of every action's fields, so a field
1407
+ * that only one action uses reads as a requirement for all of them, and a
1408
+ * caller that omits it gets whichever branch's complaint fires first --
1409
+ * "extensionId is required" from an action that never wanted it. Naming the
1410
+ * action and its own list turns that into something a caller can act on
1411
+ * without reading the source, which is the whole reason the union is there.
1412
+ */
1413
+ function needs(record, action, spec) {
1414
+ const missing = (spec[action] ?? []).filter((field) => typeof record[field] !== "string" || record[field].trim() === "");
1415
+ if (missing.length > 0) throw new Error(`box_plugins ${action} needs ${missing.join(" and ")}. For this action the arguments are: ${(spec[action] ?? []).join(", ")}. Actions that need none: list, installed, container, prune, bundle (list).`);
1416
+ }
1417
+ register(ctx, {
1418
+ name: "box_plugins",
1419
+ description: "Plugins a box has, and the ones it can have: the repository, a container, install, import, export, bundles, and the dependency graph. Installing and importing are waited on, so a call ends with a result rather than a task id. One containerId covers all of it: the daemon names that argument differently per method, which is a detail of its wire format and not something a caller should have to remember per verb.",
1420
+ parameters: {
1421
+ type: "object",
1422
+ properties: {
1423
+ action: actionProperty("plugins", [
1424
+ "list",
1425
+ "installed",
1426
+ "container",
1427
+ "add",
1428
+ "add-extension",
1429
+ "copy",
1430
+ "remove-plugin",
1431
+ "import",
1432
+ "export",
1433
+ "export-installed",
1434
+ "import-workspace",
1435
+ "install-bundle",
1436
+ "bundle",
1437
+ "prune",
1438
+ "graph"
1439
+ ]),
1440
+ containerId: {
1441
+ type: "string",
1442
+ description: "Container to read, install into, or graph."
1443
+ },
1444
+ profile: {
1445
+ type: "string",
1446
+ description: "Profile inside the container. Defaults to web."
1447
+ },
1448
+ spec: {
1449
+ type: "string",
1450
+ description: "For add, what to install; for import, the directory to import."
1451
+ },
1452
+ extensionId: {
1453
+ type: "string",
1454
+ description: "A repository row id (img-...). For copy, export, install-bundle."
1455
+ },
1456
+ name: {
1457
+ type: "string",
1458
+ description: "A plugin package name for remove-plugin, e.g. @scope/name. Also a bundle name."
1459
+ },
1460
+ path: {
1461
+ type: "string",
1462
+ description: "Absolute path, for importing from or exporting to the workspace."
1463
+ },
1464
+ destination: {
1465
+ type: "string",
1466
+ description: "Absolute path to write an export to."
1467
+ },
1468
+ overwrite: {
1469
+ type: "boolean",
1470
+ description: "Install a bundle, replacing what is there instead of keeping it."
1471
+ },
1472
+ ids: {
1473
+ type: "array",
1474
+ items: { type: "string" },
1475
+ description: "Repository row ids that make up a bundle."
1476
+ },
1477
+ subaction: {
1478
+ type: "string",
1479
+ enum: [
1480
+ "list",
1481
+ "create",
1482
+ "import",
1483
+ "export",
1484
+ "delete"
1485
+ ],
1486
+ description: "What to do with a bundle."
1487
+ }
1488
+ },
1489
+ required: ["action"]
1490
+ },
1491
+ timeoutMs: LONG_BUDGET_MS,
1492
+ output: textOutput({
1493
+ action: { type: "string" },
1494
+ result: { type: "object" }
1495
+ }, ["action", "result"], (value) => "box_plugins " + String(value.action) + ":\n" + JSON.stringify(value.result, null, 2).slice(0, 6e3)),
1496
+ async execute(args) {
1497
+ const record = args;
1498
+ const action = required(record, "action");
1499
+ const rpc = getRpc();
1500
+ needs(record, action, {
1501
+ add: ["containerId", "spec"],
1502
+ "add-extension": ["containerId", "spec"],
1503
+ copy: ["containerId", "extensionId"],
1504
+ "remove-plugin": ["containerId", "name"],
1505
+ import: ["spec"],
1506
+ export: ["extensionId"],
1507
+ "export-installed": [
1508
+ "containerId",
1509
+ "path",
1510
+ "destination"
1511
+ ],
1512
+ "import-workspace": ["containerId", "path"],
1513
+ "install-bundle": ["containerId", "extensionId"],
1514
+ container: ["containerId"],
1515
+ graph: ["containerId"]
1516
+ });
1517
+ const containerId = optional(record, "containerId");
1518
+ const profile = optional(record, "profile") ?? "web";
1519
+ const needContainer = (what) => {
1520
+ if (containerId === void 0) throw new Error(action + " " + what + " needs a containerId. box_overview lists them.");
1521
+ return containerId;
1522
+ };
1523
+ switch (action) {
1524
+ case "list": return {
1525
+ action,
1526
+ result: { repository: asArray(await rpc.call("list_repository_extensions")) }
1527
+ };
1528
+ case "installed": return {
1529
+ action,
1530
+ result: asObject(await rpc.call("list_installed_plugins"))
1531
+ };
1532
+ case "container": return {
1533
+ action,
1534
+ result: { plugins: await rpc.call("container_list_plugins", {
1535
+ containerId: needContainer("listing"),
1536
+ profile
1537
+ }) }
1538
+ };
1539
+ case "add": {
1540
+ const spec = required(record, "spec");
1541
+ const task = await runTask("container_plugin_add", {
1542
+ containerId: needContainer("installing into"),
1543
+ profile,
1544
+ spec
1545
+ });
1546
+ return {
1547
+ action,
1548
+ result: {
1549
+ kind: task.kind,
1550
+ logPath: task.logPath ?? ""
1551
+ }
1552
+ };
1553
+ }
1554
+ case "import": {
1555
+ const task = await runTask("import_repository_extension", { source: required(record, "spec") });
1556
+ return {
1557
+ action,
1558
+ result: {
1559
+ kind: task.kind,
1560
+ logPath: task.logPath ?? ""
1561
+ }
1562
+ };
1563
+ }
1564
+ case "export": return {
1565
+ action,
1566
+ result: asObject(await rpc.call("export_repository_extension", { repositoryId: required(record, "extensionId") }))
1567
+ };
1568
+ case "remove": return {
1569
+ action,
1570
+ result: asObject(await rpc.call("remove_repository_extension", { id: required(record, "extensionId") }))
1571
+ };
1572
+ case "add-extension": {
1573
+ const task = await runTask("enqueue_container_extension_add", {
1574
+ id: needContainer("adding to"),
1575
+ profile,
1576
+ source: required(record, "spec")
1577
+ });
1578
+ return {
1579
+ action,
1580
+ result: {
1581
+ kind: task.kind,
1582
+ logPath: task.logPath ?? ""
1583
+ }
1584
+ };
1585
+ }
1586
+ case "copy": {
1587
+ const task = await runTask("enqueue_container_extension_copy", {
1588
+ id: needContainer("copying into"),
1589
+ profile,
1590
+ repositoryId: required(record, "extensionId")
1591
+ });
1592
+ return {
1593
+ action,
1594
+ result: {
1595
+ kind: task.kind,
1596
+ logPath: task.logPath ?? ""
1597
+ }
1598
+ };
1599
+ }
1600
+ case "remove-plugin": return {
1601
+ action,
1602
+ result: asObject(await rpc.call("remove_repository_plugin", {
1603
+ id: needContainer("removing from"),
1604
+ profile,
1605
+ name: required(record, "name")
1606
+ }))
1607
+ };
1608
+ case "export-installed": {
1609
+ const task = await runTask("enqueue_plugin_export", {
1610
+ sourceContainerId: needContainer("exporting from"),
1611
+ sourcePath: required(record, "path"),
1612
+ destination: required(record, "destination")
1613
+ });
1614
+ return {
1615
+ action,
1616
+ result: {
1617
+ kind: task.kind,
1618
+ logPath: task.logPath ?? ""
1619
+ }
1620
+ };
1621
+ }
1622
+ case "import-workspace": {
1623
+ const task = await runTask("enqueue_workspace_extension_import", {
1624
+ id: needContainer("importing into"),
1625
+ relativePath: required(record, "path")
1626
+ });
1627
+ return {
1628
+ action,
1629
+ result: {
1630
+ kind: task.kind,
1631
+ logPath: task.logPath ?? ""
1632
+ }
1633
+ };
1634
+ }
1635
+ case "install-bundle": {
1636
+ const task = await runTask("enqueue_container_bundle_install", {
1637
+ id: needContainer("installing into"),
1638
+ profile,
1639
+ bundleId: required(record, "extensionId"),
1640
+ conflict: flag(record, "overwrite") ? "overwrite" : "keep"
1641
+ });
1642
+ return {
1643
+ action,
1644
+ result: {
1645
+ kind: task.kind,
1646
+ logPath: task.logPath ?? ""
1647
+ }
1648
+ };
1649
+ }
1650
+ case "prune": return {
1651
+ action,
1652
+ result: { removed: asArray(await rpc.call("prune_repository_extensions")) }
1653
+ };
1654
+ case "bundle": {
1655
+ const name = optional(record, "name");
1656
+ const ids = Array.isArray(record.ids) ? record.ids.filter((e) => typeof e === "string") : [];
1657
+ const sub = optional(record, "subaction") ?? (name === void 0 ? "list" : ids.length === 0 ? "create" : "export");
1658
+ if (sub === "list") return {
1659
+ action,
1660
+ result: { bundles: asArray(await rpc.call("list_bundles")) }
1661
+ };
1662
+ if (name === void 0 && ids.length === 0) throw new Error("bundle " + sub + " needs a name or ids.");
1663
+ if (sub === "create") return {
1664
+ action,
1665
+ result: asObject(await rpc.call("create_extension_bundle", {
1666
+ name,
1667
+ repositoryIds: ids
1668
+ }))
1669
+ };
1670
+ if (sub === "export") return {
1671
+ action,
1672
+ result: asObject(await rpc.call("export_bundle", {
1673
+ bundleId: name,
1674
+ destination: required(record, "destination"),
1675
+ mode: "archive"
1676
+ }))
1677
+ };
1678
+ if (sub === "delete") return {
1679
+ action,
1680
+ result: asObject(await rpc.call("delete_extension_bundle", { id: name }))
1681
+ };
1682
+ return {
1683
+ action,
1684
+ result: asObject(await rpc.call("import_bundle", {
1685
+ archive: required(record, "destination"),
1686
+ conflict: flag(record, "overwrite") ? "overwrite" : "keep"
1687
+ }))
1688
+ };
1689
+ }
1690
+ case "graph": return {
1691
+ action,
1692
+ result: asObject(await rpc.call("plugin_dependency_graph", {
1693
+ id: needContainer("graphing"),
1694
+ kind: "container"
1695
+ }))
1696
+ };
1697
+ default: badAction("plugins", action, [
1698
+ "list",
1699
+ "installed",
1700
+ "container",
1701
+ "add",
1702
+ "add-extension",
1703
+ "copy",
1704
+ "remove-plugin",
1705
+ "import",
1706
+ "export",
1707
+ "export-installed",
1708
+ "import-workspace",
1709
+ "install-bundle",
1710
+ "bundle",
1711
+ "prune",
1712
+ "graph"
1713
+ ]);
1714
+ }
1715
+ }
1716
+ });
1717
+ register(ctx, {
1718
+ name: "box_create",
1719
+ description: "Make a container, and look at one in full. An agent that can start and stop containers but not create them is doing half the job: create from a template, create from a DSH version, describe what exists, get the URL a running one is serving, or list the directories a container can open. Creating from a template starts the container and returns its id, so the next call can act on it. A plain create materialises the tree without starting anything; start it with box_lifecycle. The name has to be safe for a path, and is not the id.",
1720
+ parameters: {
1721
+ type: "object",
1722
+ properties: {
1723
+ action: actionProperty("create", [
1724
+ "from-template",
1725
+ "from-version",
1726
+ "describe",
1727
+ "url",
1728
+ "browse"
1729
+ ]),
1730
+ name: {
1731
+ type: "string",
1732
+ description: "Name for a new container, or which one to describe."
1733
+ },
1734
+ template: {
1735
+ type: "string",
1736
+ description: "Template to create from. Defaults to the one box_templates reports."
1737
+ },
1738
+ version: {
1739
+ type: "string",
1740
+ description: "DSH version to create from, for from-version."
1741
+ },
1742
+ containerId: {
1743
+ type: "string",
1744
+ description: "Which container, for describe, url and browse."
1745
+ },
1746
+ profile: {
1747
+ type: "string",
1748
+ description: "Profile to create with. Defaults to web."
1749
+ },
1750
+ start: {
1751
+ type: "boolean",
1752
+ description: "Start it after creating. Only from-version; from-template always starts."
1753
+ }
1754
+ },
1755
+ required: ["action"]
1756
+ },
1757
+ timeoutMs: LONG_BUDGET_MS,
1758
+ output: textOutput({
1759
+ action: { type: "string" },
1760
+ result: { type: "object" }
1761
+ }, ["action", "result"], (value) => "box_create " + String(value.action) + ":\n" + JSON.stringify(value.result, null, 2).slice(0, 6e3)),
1762
+ async execute(args) {
1763
+ const record = args;
1764
+ const action = required(record, "action");
1765
+ const rpc = getRpc();
1766
+ const profile = optional(record, "profile") ?? "web";
1767
+ const needId = () => {
1768
+ const id = optional(record, "containerId");
1769
+ if (id === void 0) throw new Error("create " + action + " needs a containerId. box_overview lists them.");
1770
+ return id;
1771
+ };
1772
+ switch (action) {
1773
+ case "from-template": {
1774
+ const name = required(record, "name");
1775
+ const template = optional(record, "template");
1776
+ if (template === void 0) throw new Error("from-template needs a template name; box_templates with action list reports what exists.");
1777
+ const task = await runTask("create_container_from_template", {
1778
+ name,
1779
+ template,
1780
+ profile
1781
+ });
1782
+ return {
1783
+ action,
1784
+ result: {
1785
+ kind: task.kind,
1786
+ name,
1787
+ logPath: task.logPath ?? ""
1788
+ }
1789
+ };
1790
+ }
1791
+ case "from-version": {
1792
+ const name = required(record, "name");
1793
+ const version = optional(record, "version");
1794
+ if (version === void 0) throw new Error("from-version needs a DSH version; box_templates with action catalog reports what exists.");
1795
+ const created = await rpc.call("create_container", {
1796
+ name,
1797
+ version,
1798
+ profile
1799
+ });
1800
+ const id = created && created.id !== void 0 ? String(created.id) : "";
1801
+ if (id !== "" && flag(record, "start")) await runTask("enqueue_container_start", { id });
1802
+ return {
1803
+ action,
1804
+ result: {
1805
+ ...created,
1806
+ started: id !== "" && flag(record, "start")
1807
+ }
1808
+ };
1809
+ }
1810
+ case "describe": return {
1811
+ action,
1812
+ result: asObject(await rpc.call("describe_container", { id: needId() }))
1813
+ };
1814
+ case "url": return {
1815
+ action,
1816
+ result: asObject(await rpc.call("container_url", { id: needId() }))
1817
+ };
1818
+ case "browse": return {
1819
+ action,
1820
+ result: asObject(await rpc.call("browse_container_paths", { id: needId() }))
1821
+ };
1822
+ default: badAction("create", action, [
1823
+ "from-template",
1824
+ "from-version",
1825
+ "describe",
1826
+ "url",
1827
+ "browse"
1828
+ ]);
1829
+ }
1830
+ }
1831
+ });
1832
+ register(ctx, {
1833
+ name: "box_templates",
1834
+ description: "The templates a box can create containers from, and the DSH versions behind them: list them, read one, pull a newer one, import an archive, export one out, or remove one. Pulling and importing are waited on. A template is the shape a new container starts from, so this is what you look at before box_create from-template. Removing one is refused while a container was built from it.",
1835
+ parameters: {
1836
+ type: "object",
1837
+ properties: {
1838
+ action: actionProperty("templates", [
1839
+ "list",
1840
+ "show",
1841
+ "pull",
1842
+ "import",
1843
+ "export",
1844
+ "remove",
1845
+ "prune",
1846
+ "catalog"
1847
+ ]),
1848
+ name: {
1849
+ type: "string",
1850
+ description: "Which template, or which DSH version to pull."
1851
+ },
1852
+ archive: {
1853
+ type: "string",
1854
+ description: "Absolute path of an archive to import."
1855
+ },
1856
+ destination: {
1857
+ type: "string",
1858
+ description: "Where to write an export. Defaults to a temp file."
1859
+ },
1860
+ versions: {
1861
+ type: "boolean",
1862
+ description: "With list, also report installed DSH versions."
1863
+ }
1864
+ },
1865
+ required: ["action"]
1866
+ },
1867
+ timeoutMs: LONG_BUDGET_MS,
1868
+ output: textOutput({
1869
+ action: { type: "string" },
1870
+ result: { type: "object" }
1871
+ }, ["action", "result"], (value) => "box_templates " + String(value.action) + ":\n" + JSON.stringify(value.result, null, 2).slice(0, 6e3)),
1872
+ async execute(args) {
1873
+ const record = args;
1874
+ const action = required(record, "action");
1875
+ const rpc = getRpc();
1876
+ switch (action) {
1877
+ case "list": {
1878
+ const result = { templates: asArray(await rpc.call("list_templates")) };
1879
+ if (flag(record, "versions")) result.dshVersions = asObject(await rpc.call("list_installed_dsh_versions"));
1880
+ return {
1881
+ action,
1882
+ result
1883
+ };
1884
+ }
1885
+ case "show": return {
1886
+ action,
1887
+ result: asObject(await rpc.call("read_template", { name: required(record, "name") }))
1888
+ };
1889
+ case "catalog": return {
1890
+ action,
1891
+ result: asObject(await rpc.call("list_dsh_catalog"))
1892
+ };
1893
+ case "pull": {
1894
+ const task = await runTask("pull_template", { name: required(record, "name") });
1895
+ return {
1896
+ action,
1897
+ result: {
1898
+ kind: task.kind,
1899
+ logPath: task.logPath ?? ""
1900
+ }
1901
+ };
1902
+ }
1903
+ case "import": {
1904
+ const params = { archive: required(record, "archive") };
1905
+ const name = optional(record, "name");
1906
+ if (name !== void 0) params.name = name;
1907
+ return {
1908
+ action,
1909
+ result: asObject(await rpc.call("import_template", params))
1910
+ };
1911
+ }
1912
+ case "export": {
1913
+ const params = { name: required(record, "name") };
1914
+ const destination = optional(record, "destination");
1915
+ if (destination !== void 0) params.destination = destination;
1916
+ return {
1917
+ action,
1918
+ result: asObject(await rpc.call("export_template", params))
1919
+ };
1920
+ }
1921
+ case "remove": return {
1922
+ action,
1923
+ result: asObject(await rpc.call("remove_template", { name: required(record, "name") }))
1924
+ };
1925
+ case "prune": return {
1926
+ action,
1927
+ result: { removed: asArray(await rpc.call("prune_template_snapshots")) }
1928
+ };
1929
+ default: badAction("templates", action, [
1930
+ "list",
1931
+ "show",
1932
+ "pull",
1933
+ "import",
1934
+ "export",
1935
+ "remove",
1936
+ "prune",
1937
+ "catalog"
1938
+ ]);
1939
+ }
1940
+ }
1941
+ });
1942
+ register(ctx, {
1943
+ name: "box_settings",
1944
+ description: "The box itself rather than anything in it: the toolchains and DSH versions it can use, the catalogue of versions available, the mirrors and runtime directory it is configured with, and the templates behind them. The read actions need nothing. The ones that change the box for everything on this machine -- moving where its data lives, changing the mirrors, removing an installed DSH version -- need confirm true, so they cannot happen by accident from a half-remembered argument. Moving the runtime directory rebuilds the plugin set and asks for a restart; the reply says so.",
1945
+ parameters: {
1946
+ type: "object",
1947
+ properties: {
1948
+ action: actionProperty("settings", [
1949
+ "toolchains",
1950
+ "installed",
1951
+ "catalog",
1952
+ "refresh-catalog",
1953
+ "data",
1954
+ "references",
1955
+ "template-info",
1956
+ "template-list",
1957
+ "save-mirrors",
1958
+ "set-runtime-directory",
1959
+ "uninstall-dsh"
1960
+ ]),
1961
+ version: {
1962
+ type: "string",
1963
+ description: "Which DSH version, for uninstall-dsh or template-info."
1964
+ },
1965
+ runtimeDirectory: {
1966
+ type: "string",
1967
+ description: "Absolute path to move the box data to."
1968
+ },
1969
+ githubMirror: {
1970
+ type: "string",
1971
+ description: "GitHub mirror URL. Omit to clear it."
1972
+ },
1973
+ npmRegistry: {
1974
+ type: "string",
1975
+ description: "npm registry URL. Omit to clear it."
1976
+ },
1977
+ confirm: {
1978
+ type: "boolean",
1979
+ description: "Required true for the three actions that change the box itself."
1980
+ }
1981
+ },
1982
+ required: ["action"]
1983
+ },
1984
+ timeoutMs: LONG_BUDGET_MS,
1985
+ output: textOutput({
1986
+ action: { type: "string" },
1987
+ result: { type: "object" }
1988
+ }, ["action", "result"], (value) => "box_settings " + String(value.action) + ":\n" + JSON.stringify(value.result, null, 2).slice(0, 6e3)),
1989
+ async execute(args) {
1990
+ const record = args;
1991
+ const action = required(record, "action");
1992
+ const rpc = getRpc();
1993
+ const what = {
1994
+ "save-mirrors": "changing the mirrors every install goes through",
1995
+ "set-runtime-directory": "moving where all box data lives",
1996
+ "uninstall-dsh": "removing an installed DSH version"
1997
+ }[action];
1998
+ if (what !== void 0 && !flag(record, "confirm")) throw new Error(action + " means " + what + ", and it affects every container and every future session, not just this one. Pass confirm true when that is what you want.");
1999
+ switch (action) {
2000
+ case "toolchains": return {
2001
+ action,
2002
+ result: asObject(await rpc.call("detect_toolchains"))
2003
+ };
2004
+ case "installed": return {
2005
+ action,
2006
+ result: asObject(await rpc.call("list_installed_dsh_versions"))
2007
+ };
2008
+ case "catalog": return {
2009
+ action,
2010
+ result: asObject(await rpc.call("list_dsh_catalog"))
2011
+ };
2012
+ case "refresh-catalog": {
2013
+ const task = await runTask("refresh_dsh_catalog", {});
2014
+ return {
2015
+ action,
2016
+ result: {
2017
+ kind: task.kind,
2018
+ logPath: task.logPath ?? ""
2019
+ }
2020
+ };
2021
+ }
2022
+ case "data": return {
2023
+ action,
2024
+ result: { entries: asArray(await rpc.call("list_data_entries")) }
2025
+ };
2026
+ case "references": return {
2027
+ action,
2028
+ result: asObject(await rpc.call("list_repository_reference_counts"))
2029
+ };
2030
+ case "template-info": return {
2031
+ action,
2032
+ result: asObject(await rpc.call("template_info", { name: required(record, "version") }))
2033
+ };
2034
+ case "template-list": return {
2035
+ action,
2036
+ result: asObject(await rpc.call("read_template_list", { name: required(record, "version") }))
2037
+ };
2038
+ case "save-mirrors": {
2039
+ const params = {};
2040
+ params.githubMirror = optional(record, "githubMirror") ?? null;
2041
+ params.npmRegistry = optional(record, "npmRegistry") ?? null;
2042
+ return {
2043
+ action,
2044
+ result: asObject(await rpc.call("save_mirror_settings", params))
2045
+ };
2046
+ }
2047
+ case "set-runtime-directory": return {
2048
+ action,
2049
+ result: asObject(await rpc.call("save_runtime_directory", { runtimeDirectory: required(record, "runtimeDirectory") }))
2050
+ };
2051
+ case "uninstall-dsh": return {
2052
+ action,
2053
+ result: asObject(await rpc.call("uninstall_dsh_version", { version: required(record, "version") }))
2054
+ };
2055
+ default: badAction("settings", action, [
2056
+ "toolchains",
2057
+ "installed",
2058
+ "catalog",
2059
+ "refresh-catalog",
2060
+ "data",
2061
+ "references",
2062
+ "template-info",
2063
+ "template-list",
2064
+ "save-mirrors",
2065
+ "set-runtime-directory",
2066
+ "uninstall-dsh"
2067
+ ]);
2068
+ }
2069
+ }
2070
+ });
2071
+ }
2072
+ /** Keep a structured RPC reply an object, so rendering never sees an array. */
2073
+ function asObject(value) {
2074
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return value;
2075
+ return { value };
2076
+ }
2077
+ /**
2078
+ * True when this plugin is running inside the container it was asked about.
2079
+ *
2080
+ * Nothing hands a plugin its own container id -- the host process is told which
2081
+ * container it is, but not the plugins it mounts -- so it is read back out of
2082
+ * where the plugin is installed, which is always
2083
+ * `<instance>/profile/profiles/<p>/node_modules/...`: the container id is a
2084
+ * directory name on the way to it.
2085
+ *
2086
+ * Comparing path segments to the id that was passed in, rather than testing the
2087
+ * id's shape, is the point. An earlier version matched a UUID 8-4-4-4-12 and
2088
+ * silently never matched anything: real ids are `container-<10 digits>-<uuid>`,
2089
+ * with a unix timestamp in front. A guard that quietly does not fire is worse
2090
+ * than no guard, because the code reads as though the case is handled.
2091
+ */
2092
+ function runsInside(containerId) {
2093
+ let here;
2094
+ try {
2095
+ here = dirname(fileURLToPath(import.meta.url));
2096
+ } catch {
2097
+ return false;
2098
+ }
2099
+ return here.split(/[\\/]/).includes(containerId);
2100
+ }
2101
+ /** Read an optional finite number, treating blank and NaN as absent. */
2102
+ function number(args, field) {
2103
+ const value = args[field];
2104
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
2105
+ return Math.round(value);
2106
+ }
2107
+ /** The daemon answers list-shaped methods with a bare array; guard that here. */
2108
+ function asArray(value) {
2109
+ if (Array.isArray(value)) return value;
2110
+ if (typeof value === "object" && value !== null) for (const key of [
2111
+ "containers",
2112
+ "templates",
2113
+ "plugins",
2114
+ "items",
2115
+ "rows"
2116
+ ]) {
2117
+ const nested = value[key];
2118
+ if (Array.isArray(nested)) return nested;
2119
+ }
2120
+ return [];
2121
+ }
2122
+ //#endregion
2123
+ //#region src/index.ts
2124
+ /** Cordis plugin name used by loader diagnostics and the patch overlay. */
2125
+ const name = "dsh-box-sandbox";
2126
+ /**
2127
+ * Services this plugin depends on. Empty for now: the client is pure
2128
+ * transport and needs no host service. Tools built on top of it will
2129
+ * declare their own.
2130
+ */
2131
+ const inject = [
2132
+ "tools",
2133
+ "attachments",
2134
+ "systemPrompt"
2135
+ ];
2136
+ /**
2137
+ * Effective configuration after defaults.
2138
+ *
2139
+ * Deliberately not a schemastery schema. A schema would make this entry
2140
+ * import `@deepseek-ai/schemastery` at module load, and a bare import that
2141
+ * cannot be resolved from wherever the plugin was installed is a *load*
2142
+ * failure -- DSH reports it as "failed to import" and the tool never mounts.
2143
+ * The two knobs are read from the environment instead, which costs a schema
2144
+ * nobody has to keep in sync and makes the bundle import nothing but node:*.
2145
+ */
2146
+ function resolveConfig(config) {
2147
+ const fromEnv = process.env.DSHBOX_RPC_TIMEOUT_MS;
2148
+ const parsed = fromEnv === void 0 ? NaN : Number(fromEnv);
2149
+ return {
2150
+ configDir: config?.configDir ?? process.env.DSHBOX_CONFIG_DIR ?? "",
2151
+ timeoutMs: config?.timeoutMs ?? (Number.isFinite(parsed) ? parsed : 3e4)
2152
+ };
2153
+ }
2154
+ /**
2155
+ * Probe the daemon without throwing.
2156
+ *
2157
+ * Returns the discovery record when the daemon answers, or the reason it
2158
+ * could not be reached. A ping failure is reported rather than raised so
2159
+ * that a status surface can render "dshbox is not running" without every
2160
+ * caller having to catch.
2161
+ */
2162
+ async function status(config) {
2163
+ const rpc = getRpc(resolveConfig(config));
2164
+ const path = discoveryPath(rpc.configDir);
2165
+ try {
2166
+ const record = rpc.discovery();
2167
+ await rpc.ping();
2168
+ return {
2169
+ discoveryPath: path,
2170
+ endpoint: record,
2171
+ problem: null
2172
+ };
2173
+ } catch (error) {
2174
+ const problem = error instanceof DshboxUnavailableError ? error.message : "dshbox daemon answered but failed its liveness probe";
2175
+ return {
2176
+ discoveryPath: path,
2177
+ endpoint: null,
2178
+ problem: error instanceof Error ? error.message + " (" + problem + ")" : problem
2179
+ };
2180
+ }
2181
+ }
2182
+ /**
2183
+ * Cross-tool guidance, contributed once as a prompt section.
2184
+ *
2185
+ * Each tool's description already says what that one tool does. What no single
2186
+ * description can carry is the order, and getting the order wrong is what costs an
2187
+ * agent the most: reading a page, acting on a control that was never on screen, and
2188
+ * treating a blocked click as a broken app.
2189
+ *
2190
+ * Registered inside a cordis effect so the section is removed when this mount is
2191
+ * disposed. A duplicate name throws in the host, which would turn a re-apply into a
2192
+ * load failure; scoping the registration to the effect is what makes that impossible.
2193
+ *
2194
+ * Deliberately short. It earns its place by carrying what no tool response repeats;
2195
+ * anything a tool already reports belongs in that tool.
2196
+ */
2197
+ const WORKFLOW_PROMPT_SECTION = "dsh-box-sandbox:page-workflow";
2198
+ const WORKFLOW_PROMPT_ORDER = 3050;
2199
+ function pageWorkflowPrompt() {
2200
+ return [
2201
+ "Debugging a page with the box_* tools:",
2202
+ "Start with box_page_text. It needs no selector and returns every control with its role,",
2203
+ "name and position, so it replaces guessing at selectors.",
2204
+ "Check the in-viewport flag before acting. A control marked below the fold exists; scroll to",
2205
+ "it with box_scroll rather than concluding it is missing.",
2206
+ "Act on what box_page_text returned: pass the role and name back to box_click_element rather",
2207
+ NaN,
2208
+ NaN,
2209
+ "landed, so believe that field, not the fact that you dispatched a click: when landed is",
2210
+ "false something covered the target, and a page that looks unchanged is not a broken app.",
2211
+ "Before typing, click the field. box_type_text reports the focused element and inserts nothing",
2212
+ "when no editable field holds focus.",
2213
+ "If a page tool cannot find a browser, box_set_browser reports which one is in use and pins a",
2214
+ "different one."
2215
+ ].join("\n");
2216
+ }
2217
+ /** Register the plugin against a freshly mounted Cordis context. */
2218
+ function apply(ctx, config) {
2219
+ resetRpc();
2220
+ getRpc(resolveConfig(config));
2221
+ applyBoxTools(ctx);
2222
+ registerManageTools(ctx);
2223
+ ctx.effect(() => () => resetRpc(), "dsh-box-sandbox.client()");
2224
+ ctx.effect(() => hostServices(ctx).systemPrompt.section({
2225
+ name: WORKFLOW_PROMPT_SECTION,
2226
+ order: WORKFLOW_PROMPT_ORDER,
2227
+ text: pageWorkflowPrompt
2228
+ }), "dsh-box-sandbox.systemPrompt()");
2229
+ }
2230
+ //#endregion
2231
+ export { DshboxRpc, DshboxUnavailableError, apply, discoveryPath, getRpc, inject, name, resetRpc, status };