@frockbot/plugin-computer 0.0.0 → 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.
Files changed (42) hide show
  1. package/frockbot.json +25 -0
  2. package/package.json +54 -6
  3. package/src/agent.test.ts +271 -0
  4. package/src/agent.ts +1419 -0
  5. package/src/backend.test.ts +149 -0
  6. package/src/backend.ts +163 -0
  7. package/src/bot.test.ts +411 -0
  8. package/src/bot.ts +831 -0
  9. package/src/client/ComputerCard.test.ts +96 -0
  10. package/src/client/ComputerCard.vue +60 -0
  11. package/src/client/ComputerStrip.test.ts +54 -0
  12. package/src/client/ComputerStrip.vue +55 -0
  13. package/src/client/ComputerViewerOverlay.vue +252 -0
  14. package/src/client/application.test.ts +373 -0
  15. package/src/client/application.ts +340 -0
  16. package/src/client/cordis-client-shim.d.ts +16 -0
  17. package/src/client/dialog-focus.ts +13 -0
  18. package/src/client/index.ts +28 -0
  19. package/src/client/state-machine.test.ts +200 -0
  20. package/src/client/state-machine.ts +172 -0
  21. package/src/client/styles.css +594 -0
  22. package/src/client/viewer.ts +58 -0
  23. package/src/control-record.ts +57 -0
  24. package/src/doctor.test.ts +247 -0
  25. package/src/env.d.ts +12 -0
  26. package/src/index.ts +6 -0
  27. package/src/manifest.ts +3 -0
  28. package/src/process-records.test.ts +178 -0
  29. package/src/process-records.ts +278 -0
  30. package/src/process-store.ts +96 -0
  31. package/src/processes.test.ts +388 -0
  32. package/src/protocol.ts +405 -0
  33. package/src/roots.ts +6 -0
  34. package/src/screenshot.test.ts +253 -0
  35. package/src/shared-provider.test.ts +56 -0
  36. package/src/shared-provider.ts +121 -0
  37. package/src/shared.ts +54 -0
  38. package/src/sync.test.ts +255 -0
  39. package/src/workspace-fixture.ts +126 -0
  40. package/tsconfig.json +19 -0
  41. package/vite.config.ts +24 -0
  42. package/README.md +0 -3
package/src/agent.ts ADDED
@@ -0,0 +1,1419 @@
1
+ // The Package that gives a Bot its Computer tools, and the caller of the
2
+ // durable-root sync (ADR 0013).
3
+ //
4
+ // "Bots invoke Computers only through the provider-neutral Computer
5
+ // interface", so the sync is reached here as `handle.sync` and never as a
6
+ // provider type: this Package does not know which Computer it is driving, and
7
+ // the reconciliation itself lives in the provider Package that does.
8
+ //
9
+ // WHEN THE SYNC RUNS. Three points, and no others:
10
+ //
11
+ // open before this Turn's first Computer tool call, so the Workspace
12
+ // the Bot is about to look at is the one object storage holds.
13
+ // signal before a later tool call in the same Turn, when the on-Computer
14
+ // watcher's change signal has moved.
15
+ // turn-end after a Turn that used the Computer, so a shell write on the
16
+ // Computer becomes a durable generation.
17
+ //
18
+ // It never runs to *reach* a Computer. Every one of those points is inside a
19
+ // Turn that already has the Computer open for this Bot: "The Agent loop,
20
+ // Memory, Skills, Package composition, and Routines function correctly while
21
+ // the Computer is hibernated and do not wake it. The Computer wakes only when
22
+ // a Bot uses it" — and while it sleeps the object-storage side is
23
+ // authoritative on its own.
24
+ //
25
+ // A sync that could not run is an outcome, not an error. "Connections to the
26
+ // Computer are expected to drop on every pause; every Computer client
27
+ // reconnects and resumes rather than treating a dropped connection as
28
+ // failure." Every run appends `computer/sync` to the session event log with
29
+ // what it moved, and nothing on this path can fail a Turn.
30
+ import {
31
+ type Session,
32
+ type SessionStore,
33
+ type ToolAttachmentV1,
34
+ type ToolDefinition,
35
+ type ToolExecutionContext,
36
+ type WorkspacePathV1,
37
+ type WorkspaceRootV1,
38
+ type WorkspaceWriterV1,
39
+ } from "@frockbot/kernel-contracts";
40
+ import {
41
+ computerBotPathKeyV1,
42
+ ComputerError,
43
+ type ComputerDoctorReportV1,
44
+ type ComputerBackgroundStateV1,
45
+ type ComputerBrowserAction,
46
+ type ComputerHandle,
47
+ computerSyncSummaryV1,
48
+ type ComputerSyncReasonV1,
49
+ type ComputerSyncSummaryV1,
50
+ } from "@frockbot/computer-core";
51
+ import {
52
+ computerGuiRefusalV1,
53
+ SCRATCH_ROOT,
54
+ shellGuiCommandV1,
55
+ } from "@frockbot/computer-host-runtime";
56
+ // Merges the Agent loop's event declarations into the cordis Context type.
57
+ import type {} from "@frockbot/kernel-agent-loop/agent";
58
+ import type { Plugin } from "cordis";
59
+ import {
60
+ computerProcessStatusV1,
61
+ COMPUTER_PROCESS_COMMAND_MAX,
62
+ isComputerProcessIdV1,
63
+ type ComputerProcessRecordV1,
64
+ type ComputerProcessStatusV1,
65
+ } from "./process-records.js";
66
+ import {
67
+ ComputerProcessLimitError,
68
+ ComputerProcessStore,
69
+ type ComputerProcessStorageV1,
70
+ } from "./process-store.js";
71
+ import {
72
+ COMPUTER_DOCTOR_ROOT_ID,
73
+ COMPUTER_SCREENSHOTS_ROOT_ID,
74
+ COMPUTER_SCREENSHOT_RETENTION,
75
+ } from "./roots.js";
76
+ import {
77
+ COMPUTER_CONTROL_RECORD_KEY,
78
+ decodeStoredComputerControlV1,
79
+ isStoredComputerControlFreshV1,
80
+ } from "./control-record.js";
81
+
82
+ export {
83
+ COMPUTER_DOCTOR_ROOT_ID,
84
+ COMPUTER_SCREENSHOTS_ROOT_ID,
85
+ COMPUTER_SCREENSHOT_RETENTION,
86
+ } from "./roots.js";
87
+
88
+ export type { ComputerProcessStorageV1 };
89
+
90
+ /**
91
+ * The Session and Turn a durable Workspace write records as its writer.
92
+ *
93
+ * Supplied by the Bot Durable Object for one admitted Turn. Absent, and
94
+ * `computer_screenshot` is not registered: "every write to a durable root
95
+ * records its writer", and outside a Turn there is no writer to record.
96
+ */
97
+ export interface ComputerWriterIdentityV1 {
98
+ sessionId: string;
99
+ turnId: string;
100
+ runId: string;
101
+ }
102
+
103
+ export interface ComputerAgentPluginConfig {
104
+ userId: string;
105
+ defaultProviderId: string;
106
+ idempotentEffects?: boolean;
107
+ writer?: ComputerWriterIdentityV1;
108
+ /**
109
+ * The Bot Durable Object storage a background process's record is written
110
+ * to. Absent, and `computer_exec{background:true}` and the three process
111
+ * tools are not offered at all: "record durable execution intent before
112
+ * invoking an external side effect", and with nowhere to record it there is
113
+ * no honest way to launch one.
114
+ */
115
+ processes?: ComputerProcessStorageV1;
116
+ /**
117
+ * Read-only access to this Bot Durable Object's Computer records. The
118
+ * dynamic prompt reads the human lease here rather than asking the
119
+ * Computer, so assembling a model request cannot wake one.
120
+ */
121
+ controlRecords?: {
122
+ get<T>(key: string): Promise<T | undefined>;
123
+ now?(): Date;
124
+ };
125
+ }
126
+
127
+ export const HUMAN_CONTROL_PROMPT_LINE =
128
+ "Your User is currently controlling the Computer; do not use it this Turn.";
129
+
130
+ /** The wake-free, Turn-scoped projection shared by prompt render and its log. */
131
+ class ComputerControlPromptProjection {
132
+ #line = "";
133
+ #loadedTurn: number | undefined;
134
+
135
+ constructor(
136
+ private readonly records: NonNullable<
137
+ ComputerAgentPluginConfig["controlRecords"]
138
+ >,
139
+ ) {}
140
+
141
+ current(): string {
142
+ return this.#line;
143
+ }
144
+
145
+ loadedTurn(): number | undefined {
146
+ return this.#loadedTurn;
147
+ }
148
+
149
+ async refresh(turn: number, session: Session): Promise<void> {
150
+ const value = await this.records.get<unknown>(COMPUTER_CONTROL_RECORD_KEY);
151
+ const record =
152
+ value === undefined ? undefined : decodeStoredComputerControlV1(value);
153
+ const active =
154
+ record &&
155
+ isStoredComputerControlFreshV1(record, this.records.now?.() ?? new Date())
156
+ ? record
157
+ : undefined;
158
+ this.#line = active ? HUMAN_CONTROL_PROMPT_LINE : "";
159
+ this.#loadedTurn = turn;
160
+ session.append({
161
+ type: "computer/injected",
162
+ turn,
163
+ text: this.#line,
164
+ ...(active
165
+ ? { ownerId: active.ownerId, expiresAt: active.expiresAt }
166
+ : {}),
167
+ });
168
+ await session.flush();
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The width and height a PNG declares in its IHDR chunk.
174
+ *
175
+ * Read here rather than asked of the Computer: `identify` is another package
176
+ * to provision and another exec to guard, and the two numbers are eight bytes
177
+ * at a fixed offset of the file the tool already holds.
178
+ */
179
+ export function pngDimensionsV1(
180
+ bytes: Uint8Array,
181
+ ): { width: number; height: number } | undefined {
182
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
183
+ if (bytes.byteLength < 24) return undefined;
184
+ if (signature.some((byte, index) => bytes[index] !== byte)) return undefined;
185
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
186
+ const width = view.getUint32(16);
187
+ const height = view.getUint32(20);
188
+ if (width === 0 || height === 0) return undefined;
189
+ return { width, height };
190
+ }
191
+
192
+ function base64Of(bytes: Uint8Array): string {
193
+ let binary = "";
194
+ for (const byte of bytes) binary += String.fromCharCode(byte);
195
+ return btoa(binary);
196
+ }
197
+
198
+ interface ExecInput {
199
+ command: string;
200
+ background: boolean;
201
+ }
202
+
203
+ function record(input: unknown): Record<string, unknown> | undefined {
204
+ return typeof input === "object" && input !== null
205
+ ? (input as Record<string, unknown>)
206
+ : undefined;
207
+ }
208
+
209
+ const MAX_EXEC_COMMAND_LENGTH = 20_000;
210
+
211
+ function decodeExec(input: unknown): ExecInput | undefined {
212
+ const value = record(input);
213
+ const command = value?.command;
214
+ if (typeof command !== "string" || !command.trim()) return undefined;
215
+ if (command.length > MAX_EXEC_COMMAND_LENGTH) return undefined;
216
+ const background = value?.background;
217
+ if (background !== undefined && typeof background !== "boolean") {
218
+ return undefined;
219
+ }
220
+ return { command, background: background === true };
221
+ }
222
+
223
+ /** The durable root a finished process's log tail is mirrored into. */
224
+ export const COMPUTER_PROCESSES_ROOT_ID = "processes";
225
+ /** Log bytes mirrored into the durable root on a completion. */
226
+ export const COMPUTER_PROCESS_MIRROR_BYTES = 64_000;
227
+
228
+ function decodeProcessId(input: unknown): string | undefined {
229
+ const value = record(input)?.processId;
230
+ return isComputerProcessIdV1(value) ? value : undefined;
231
+ }
232
+
233
+ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
234
+ const value = record(input);
235
+ switch (value?.action) {
236
+ case "snapshot":
237
+ return { type: "snapshot" };
238
+ case "navigate":
239
+ return typeof value.url === "string" && value.url
240
+ ? { type: "navigate", url: value.url }
241
+ : undefined;
242
+ case "click":
243
+ return typeof value.role === "string" && typeof value.name === "string"
244
+ ? {
245
+ type: "click",
246
+ role: value.role,
247
+ name: value.name,
248
+ exact: typeof value.exact === "boolean" ? value.exact : undefined,
249
+ }
250
+ : undefined;
251
+ case "fill":
252
+ return typeof value.label === "string" && typeof value.text === "string"
253
+ ? {
254
+ type: "fill",
255
+ label: value.label,
256
+ text: value.text,
257
+ exact: typeof value.exact === "boolean" ? value.exact : undefined,
258
+ }
259
+ : undefined;
260
+ case "press":
261
+ return typeof value.key === "string"
262
+ ? { type: "press", key: value.key }
263
+ : undefined;
264
+ case "wait": {
265
+ const milliseconds = value.milliseconds ?? 500;
266
+ return typeof milliseconds === "number" &&
267
+ milliseconds >= 0 &&
268
+ milliseconds <= 30_000
269
+ ? { type: "wait", milliseconds }
270
+ : undefined;
271
+ }
272
+ default:
273
+ return undefined;
274
+ }
275
+ }
276
+
277
+ function failure(error: unknown): { content: string; isError: true } {
278
+ if (error instanceof ComputerError) {
279
+ if (error.code === "human-control-active") {
280
+ return {
281
+ content:
282
+ "The user is controlling this Computer; do not retry this Turn",
283
+ isError: true,
284
+ };
285
+ }
286
+ if (error.code === "updating") {
287
+ const label = error.message.trim();
288
+ return {
289
+ content: `The Computer is updating (${label}); try again shortly`,
290
+ isError: true,
291
+ };
292
+ }
293
+ return { content: error.message, isError: true };
294
+ }
295
+ return {
296
+ content: error instanceof Error ? error.message : String(error),
297
+ isError: true,
298
+ };
299
+ }
300
+
301
+ function text(bytes: Uint8Array): string {
302
+ return new TextDecoder().decode(bytes);
303
+ }
304
+
305
+ /**
306
+ * The Turn's sync state, and the only place this Package decides to sync.
307
+ *
308
+ * Deep and small on purpose: `beforeUse` and `afterTurn` are the whole
309
+ * surface, they never throw, and every path through them either records a
310
+ * `computer/sync` event or has nothing to record. A caller cannot get the
311
+ * policy wrong because there is no way to ask for a sync at another time.
312
+ */
313
+ class ComputerTurnSync {
314
+ #turn = 0;
315
+ #pulled = false;
316
+ #used = false;
317
+ #signal: string | undefined;
318
+
319
+ constructor(private readonly sessions: SessionStore) {}
320
+
321
+ /** A new Turn forgets the last one's pull, its signal, and its use. */
322
+ beginTurn(turn: number): void {
323
+ if (turn === this.#turn) return;
324
+ this.#turn = turn;
325
+ this.#pulled = false;
326
+ this.#used = false;
327
+ this.#signal = undefined;
328
+ }
329
+
330
+ turnUsedTheComputer(turn: number): boolean {
331
+ return this.#used && turn === this.#turn;
332
+ }
333
+
334
+ /**
335
+ * Pull before the Turn's first Computer tool call; on later calls, sync
336
+ * again only when the on-Computer watcher says something changed.
337
+ */
338
+ async beforeUse(
339
+ computer: ComputerHandle,
340
+ sessionId: string,
341
+ signal: AbortSignal,
342
+ ): Promise<void> {
343
+ this.#used = true;
344
+ const sync = computer.sync;
345
+ if (!sync) return;
346
+ try {
347
+ if (!this.#pulled) {
348
+ this.#pulled = true;
349
+ await this.record(
350
+ sessionId,
351
+ "open",
352
+ await sync.reconcile("open", { signal }),
353
+ );
354
+ this.#signal = await sync.signal({ signal });
355
+ return;
356
+ }
357
+ const current = await sync.signal({ signal });
358
+ if (current === undefined || current === this.#signal) return;
359
+ this.#signal = current;
360
+ await this.record(
361
+ sessionId,
362
+ "signal",
363
+ await sync.reconcile("signal", { signal }),
364
+ );
365
+ } catch (error) {
366
+ // The Turn is never blocked by its sync, whatever the provider did.
367
+ await this.record(
368
+ sessionId,
369
+ "open",
370
+ computerSyncSummaryV1(
371
+ "unavailable",
372
+ error instanceof Error ? error.message : String(error),
373
+ ),
374
+ );
375
+ }
376
+ }
377
+
378
+ /** Push after a Turn that used the Computer, and only then. */
379
+ async afterTurn(computer: ComputerHandle, sessionId: string): Promise<void> {
380
+ this.#used = false;
381
+ const sync = computer.sync;
382
+ if (!sync) return;
383
+ await this.record(sessionId, "turn-end", await sync.reconcile("turn-end"));
384
+ }
385
+
386
+ /** The Turn could not be given a Computer at all; that is also an outcome. */
387
+ unavailable(sessionId: string, reason: unknown): Promise<void> {
388
+ return this.record(
389
+ sessionId,
390
+ "turn-end",
391
+ computerSyncSummaryV1(
392
+ "unavailable",
393
+ reason instanceof Error ? reason.message : String(reason),
394
+ ),
395
+ );
396
+ }
397
+
398
+ private async record(
399
+ sessionId: string,
400
+ reason: ComputerSyncReasonV1,
401
+ summary: ComputerSyncSummaryV1,
402
+ ): Promise<void> {
403
+ const session = this.sessions.get(sessionId);
404
+ if (!session || session.disposed) return;
405
+ session.append({
406
+ type: "computer/sync",
407
+ turn: Math.max(1, this.#turn),
408
+ reason,
409
+ ...summary,
410
+ });
411
+ // The record is durable before anything reports the sync happened.
412
+ await session.flush();
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Keeps the newest {@link COMPUTER_SCREENSHOT_RETENTION} captures for one Bot.
418
+ *
419
+ * A screenshot of a logged-in page is User-scoped content that outlives its
420
+ * Turn, so the root is bounded rather than allowed to grow for the life of the
421
+ * Computer. Pruning is a best effort: a capture that was recorded is never
422
+ * failed because an older one could not be removed.
423
+ */
424
+ async function prune(
425
+ workspace: NonNullable<ComputerHandle["workspace"]>,
426
+ root: WorkspaceRootV1,
427
+ botKey: string,
428
+ writer: WorkspaceWriterV1,
429
+ ): Promise<void> {
430
+ const listed = await workspace.list({
431
+ root,
432
+ prefix: botKey,
433
+ limit: COMPUTER_SCREENSHOT_RETENTION * 4,
434
+ });
435
+ if (listed.status !== "ok") return;
436
+ // Ordered by when each capture was written, not by its path: a path names
437
+ // the Turn and the ordinal within it, and neither sorts chronologically
438
+ // across Turns. The generation is the record of when, so it decides.
439
+ const sorted = [...listed.entries].sort((left, right) => {
440
+ const order = left.generation.writtenAt.localeCompare(
441
+ right.generation.writtenAt,
442
+ );
443
+ return order !== 0 ? order : left.path.path.localeCompare(right.path.path);
444
+ });
445
+ const excess = sorted.length - COMPUTER_SCREENSHOT_RETENTION;
446
+ for (let index = 0; index < excess; index += 1) {
447
+ const entry = sorted[index]!;
448
+ await workspace.delete({
449
+ path: entry.path,
450
+ writer,
451
+ expectedGenerationId: entry.generation.generationId,
452
+ });
453
+ }
454
+ }
455
+
456
+ async function useComputer<T>(
457
+ computer: ComputerHandle,
458
+ run: (computer: ComputerHandle) => Promise<T>,
459
+ ): Promise<T> {
460
+ try {
461
+ return await run(computer);
462
+ } finally {
463
+ await computer.close();
464
+ }
465
+ }
466
+
467
+ export function createComputerAgentPlugin(
468
+ config: ComputerAgentPluginConfig,
469
+ ): Plugin.Function {
470
+ const userId = config.userId.trim();
471
+ const defaultProviderId = config.defaultProviderId.trim();
472
+ if (!userId) throw new Error("Computer user id must be non-empty");
473
+ if (!defaultProviderId) {
474
+ throw new Error("Computer default provider id must be non-empty");
475
+ }
476
+
477
+ const plugin: Plugin.Function = (ctx) => {
478
+ // One Computer per User (ADR 0012): the assignment is keyed by the User,
479
+ // and the Bot attaches to it as a tenant.
480
+ const identity = { userId };
481
+ const turnSync = new ComputerTurnSync(ctx.sessions);
482
+ const controlPrompt = config.controlRecords
483
+ ? new ComputerControlPromptProjection(config.controlRecords)
484
+ : undefined;
485
+ // The Turn ordinal a `computer/process` event is recorded under. The
486
+ // Agent loop knows it; a tool context does not, so it is caught where the
487
+ // loop already announces it.
488
+ let currentTurn = 1;
489
+ const turnOf = (_context: ToolExecutionContext): number => currentTurn;
490
+ const attach = async (botId: string, signal: AbortSignal) => {
491
+ if (!ctx.computers.assignment(identity)) {
492
+ ctx.computers.assign(identity, defaultProviderId);
493
+ }
494
+ return ctx.computers.open(identity, { botId }, { signal });
495
+ };
496
+ /**
497
+ * Opens the Computer for one tool call and reconciles the durable roots
498
+ * before the Bot looks at them. The sync is inside `open` rather than
499
+ * beside each tool so no Computer tool can be added that skips it.
500
+ */
501
+ const open = async (
502
+ botId: string,
503
+ sessionId: string,
504
+ signal: AbortSignal,
505
+ ) => {
506
+ const computer = await attach(botId, signal);
507
+ await turnSync.beforeUse(computer, sessionId, signal);
508
+ await selfCheck(computer, botId, signal);
509
+ return computer;
510
+ };
511
+
512
+ const execTool: ToolDefinition = {
513
+ name: "computer_exec",
514
+ // The desktop half of the Computer: the shell, the screen, and the
515
+ // processes a shell left running. Offered to an `executor` subagent,
516
+ // which has the full work toolset, and to a `computerUse` one, whose
517
+ // whole job is the desktop; never to `browserUse`, which drives pages
518
+ // and not the box, and never to the two video roles, which have no
519
+ // Computer at all.
520
+ admission: {
521
+ turnTypes: ["chat", "automation", "subagent"],
522
+ subagentRoles: ["executor", "computerUse"],
523
+ },
524
+ idempotent: config.idempotentEffects === true,
525
+ description: [
526
+ "Run a shell command in the Bot's selected persistent Computer. New calls are blocked while the user has taken control.",
527
+ "With background:true the command keeps running after this call returns and after this Turn ends, and you get a processId to check later.",
528
+ "A background process runs only while the Computer is awake. Nothing keeps it awake for you: if the Computer hibernates first, the outcome is reported as unknown, with whatever log was durable at the time.",
529
+ `${SCRATCH_ROOT} (also $FROCKBOT_SCRATCH) is scratch shared with your User's other Bots: it survives hibernation but is not durable and never reaches storage, so keep nothing there you cannot lose.`,
530
+ "The Computer's GUI is never driven from the shell; use computer_browser and computer_screenshot instead of launching or poking at a browser yourself.",
531
+ ].join(" "),
532
+ inputSchema: {
533
+ type: "object",
534
+ properties: {
535
+ command: { type: "string", maxLength: MAX_EXEC_COMMAND_LENGTH },
536
+ background: {
537
+ type: "boolean",
538
+ description:
539
+ "Start the command and return a processId instead of waiting for it.",
540
+ },
541
+ },
542
+ required: ["command"],
543
+ additionalProperties: false,
544
+ },
545
+ validate: (input) => decodeExec(input) !== undefined,
546
+ execute: async (input, context) => {
547
+ const decoded = decodeExec(input);
548
+ if (!decoded)
549
+ return {
550
+ content: `A command of at most ${MAX_EXEC_COMMAND_LENGTH} characters is required`,
551
+ isError: true,
552
+ };
553
+ // "The GUI is never driven from the shell" (parity row 33), refused at
554
+ // the seam where the model can be told why. This is policy and not a
555
+ // boundary — a regex over a shell string is defeatable, and the
556
+ // Computer is the User's trust boundary anyway — so it is paired with
557
+ // a PATH shim on the Computer that prints the same sentence, and both
558
+ // exist to make the sanctioned surface the easy one.
559
+ const guiCommand = shellGuiCommandV1(decoded.command);
560
+ if (guiCommand) {
561
+ return { content: computerGuiRefusalV1(guiCommand), isError: true };
562
+ }
563
+ if (decoded.background) {
564
+ return processes
565
+ ? launchBackground(decoded.command, context)
566
+ : {
567
+ content:
568
+ "A background process is recorded before it is launched; this runtime has nowhere durable to record it",
569
+ isError: true,
570
+ };
571
+ }
572
+ try {
573
+ return await useComputer(
574
+ await open(context.botId, context.sessionId, context.signal),
575
+ async (computer) => {
576
+ if (!computer.exec) {
577
+ throw new ComputerError(
578
+ "capability-unavailable",
579
+ "The selected Computer does not support command execution",
580
+ );
581
+ }
582
+ const result = await computer.exec.execute(
583
+ {
584
+ executable: "/bin/bash",
585
+ args: ["-lc", decoded.command],
586
+ timeoutMs: 120_000,
587
+ maxOutputBytes: 30_000,
588
+ },
589
+ { signal: context.signal, effectId: context.effectId },
590
+ );
591
+ return {
592
+ content: [text(result.stdout), text(result.stderr)]
593
+ .filter(Boolean)
594
+ .join("\n"),
595
+ isError: result.exitCode !== 0,
596
+ };
597
+ },
598
+ );
599
+ } catch (error) {
600
+ return failure(error);
601
+ }
602
+ },
603
+ };
604
+
605
+ const writer = config.writer;
606
+ const processes = config.processes
607
+ ? new ComputerProcessStore(config.processes)
608
+ : undefined;
609
+
610
+ /** Appends one `computer/process` line to the durable session log. */
611
+ const noteProcess = async (
612
+ sessionId: string,
613
+ turn: number,
614
+ note: {
615
+ processId: string;
616
+ action: "launch" | "check" | "logs" | "stop";
617
+ status: ComputerProcessStatusV1;
618
+ exitCode?: number;
619
+ },
620
+ ): Promise<void> => {
621
+ const session = ctx.sessions.get(sessionId);
622
+ if (!session || session.disposed) return;
623
+ session.append({
624
+ type: "computer/process",
625
+ turn: Math.max(1, turn),
626
+ ...note,
627
+ });
628
+ await session.flush();
629
+ };
630
+
631
+ /**
632
+ * Mirrors a finished process's log tail into the Package-declared
633
+ * `processes` root.
634
+ *
635
+ * Without it an image rebuild erases the only evidence a long job ever
636
+ * ran, which is an unobservable failure state. Written through the
637
+ * Workspace, so the Bot is recorded as its writer; best effort, because a
638
+ * process outcome that was read is never withheld because the mirror
639
+ * could not be written.
640
+ */
641
+ const mirrorLog = async (
642
+ computer: ComputerHandle,
643
+ context: ToolExecutionContext,
644
+ record: ComputerProcessRecordV1,
645
+ status: ComputerProcessStatusV1,
646
+ logTail: string,
647
+ ): Promise<void> => {
648
+ if (!writer || !computer.workspace) return;
649
+ if (status !== "exited" && status !== "unknown") return;
650
+ const botKey = computerBotPathKeyV1(context.botId);
651
+ const body = [
652
+ `# ${record.processId}`,
653
+ `command: ${record.command}`,
654
+ `started: ${record.startedAt}`,
655
+ `status: ${status}`,
656
+ ...(record.exitCode === undefined
657
+ ? []
658
+ : [`exit: ${String(record.exitCode)}`]),
659
+ "",
660
+ logTail.slice(-COMPUTER_PROCESS_MIRROR_BYTES),
661
+ ].join("\n");
662
+ const existing = await computer.workspace.stat({
663
+ root: {
664
+ kind: "package-declared",
665
+ userId,
666
+ packageId: "computer",
667
+ rootId: COMPUTER_PROCESSES_ROOT_ID,
668
+ },
669
+ path: `${botKey}/${record.processId}.log`,
670
+ });
671
+ await computer.workspace.write({
672
+ path: {
673
+ root: {
674
+ kind: "package-declared",
675
+ userId,
676
+ packageId: "computer",
677
+ rootId: COMPUTER_PROCESSES_ROOT_ID,
678
+ },
679
+ path: `${botKey}/${record.processId}.log`,
680
+ },
681
+ bytes: new TextEncoder().encode(body),
682
+ writer: {
683
+ kind: "bot",
684
+ botId: context.botId,
685
+ sessionId: writer.sessionId,
686
+ turnId: writer.turnId,
687
+ runId: writer.runId,
688
+ },
689
+ expectedGenerationId:
690
+ existing.status === "ok"
691
+ ? existing.entry.generation.generationId
692
+ : null,
693
+ mediaType: "text/plain",
694
+ });
695
+ };
696
+
697
+ /**
698
+ * Launches a command that outlives the Turn.
699
+ *
700
+ * The record is written *before* the launch, carrying the effect id: an
701
+ * interrupted launch leaves an intent to reconcile rather than a process
702
+ * nothing remembers, and a recovery reads its outcome instead of starting
703
+ * a second one.
704
+ */
705
+ const launchBackground = async (
706
+ command: string,
707
+ context: ToolExecutionContext,
708
+ ) => {
709
+ if (!processes || !writer) {
710
+ return {
711
+ content:
712
+ "A background process is recorded before it is launched; this runtime has nowhere durable to record it",
713
+ isError: true,
714
+ };
715
+ }
716
+ const store = processes;
717
+ try {
718
+ return await useComputer(
719
+ await open(context.botId, context.sessionId, context.signal),
720
+ async (computer) => {
721
+ if (!computer.processes) {
722
+ throw new ComputerError(
723
+ "capability-unavailable",
724
+ "The selected Computer does not support background processes",
725
+ );
726
+ }
727
+ const processId = `p-${context.effectId.replaceAll(/[^a-zA-Z0-9._-]/g, "-")}`;
728
+ const generation = await computer.processes.generation({
729
+ signal: context.signal,
730
+ });
731
+ const intent: ComputerProcessRecordV1 = {
732
+ schemaVersion: 1,
733
+ processId,
734
+ botId: context.botId,
735
+ sessionId: context.sessionId,
736
+ turnId: writer.turnId,
737
+ command,
738
+ cwd: "",
739
+ startedAt: new Date().toISOString(),
740
+ status: "starting",
741
+ generation,
742
+ effectId: context.effectId,
743
+ logPath: "",
744
+ };
745
+ await store.record({ ...intent, cwd: "/", logPath: "/" });
746
+ const launched = await computer.processes.launch(
747
+ { processId, command },
748
+ { signal: context.signal, effectId: context.effectId },
749
+ );
750
+ const running: ComputerProcessRecordV1 = {
751
+ ...intent,
752
+ status: "running",
753
+ generation: launched.generation || generation,
754
+ cwd: launched.cwd,
755
+ logPath: launched.logPath,
756
+ pid: launched.pid,
757
+ };
758
+ await store.update(running);
759
+ await noteProcess(context.sessionId, turnOf(context), {
760
+ processId,
761
+ action: "launch",
762
+ status: "running",
763
+ });
764
+ return {
765
+ content: JSON.stringify({
766
+ processId,
767
+ pid: launched.pid,
768
+ status: "running",
769
+ command,
770
+ cwd: launched.cwd,
771
+ startedAt: running.startedAt,
772
+ note: "This process runs while the Computer is awake and outlives this Turn. Nothing keeps the Computer awake for it; if it hibernates first, computer_process_check answers unknown.",
773
+ }),
774
+ isError: false,
775
+ };
776
+ },
777
+ );
778
+ } catch (error) {
779
+ if (error instanceof ComputerProcessLimitError) {
780
+ return { content: error.message, isError: true };
781
+ }
782
+ return failure(error);
783
+ }
784
+ };
785
+
786
+ /**
787
+ * Reads one process's outcome and records it. The reconciliation rule —
788
+ * a moved generation means the process is gone, never running — lives in
789
+ * `computerProcessStatusV1`, so no caller here can decide it differently.
790
+ */
791
+ const settle = async (
792
+ context: ToolExecutionContext,
793
+ processId: string,
794
+ action: "check" | "logs" | "stop",
795
+ tailBytes?: number,
796
+ ) => {
797
+ if (!processes) {
798
+ return {
799
+ content: "This runtime holds no background process records",
800
+ isError: true,
801
+ };
802
+ }
803
+ const store = processes;
804
+ const held = await store.read(processId);
805
+ if (!held || held.botId !== context.botId) {
806
+ return {
807
+ content: `No background process "${processId}" is recorded for this Bot`,
808
+ isError: true,
809
+ };
810
+ }
811
+ try {
812
+ return await useComputer(
813
+ await open(context.botId, context.sessionId, context.signal),
814
+ async (computer) => {
815
+ if (!computer.processes) {
816
+ throw new ComputerError(
817
+ "capability-unavailable",
818
+ "The selected Computer does not support background processes",
819
+ );
820
+ }
821
+ const currentGeneration = await computer.processes.generation({
822
+ signal: context.signal,
823
+ });
824
+ let observed: ComputerBackgroundStateV1;
825
+ if (action === "stop") {
826
+ observed = await computer.processes.stop(processId, {
827
+ signal: context.signal,
828
+ effectId: context.effectId,
829
+ });
830
+ } else {
831
+ observed = await computer.processes.inspect(processId, {
832
+ signal: context.signal,
833
+ ...(tailBytes === undefined ? {} : { tailBytes }),
834
+ });
835
+ }
836
+ const settled = computerProcessStatusV1({
837
+ recorded: held,
838
+ currentGeneration,
839
+ observed,
840
+ });
841
+ const next: ComputerProcessRecordV1 = {
842
+ ...held,
843
+ status: settled.status,
844
+ ...(settled.exitCode === undefined
845
+ ? {}
846
+ : { exitCode: settled.exitCode }),
847
+ };
848
+ await store.update(next);
849
+ await noteProcess(context.sessionId, turnOf(context), {
850
+ processId,
851
+ action,
852
+ status: settled.status,
853
+ ...(settled.exitCode === undefined
854
+ ? {}
855
+ : { exitCode: settled.exitCode }),
856
+ });
857
+ // The evidence outlives the Computer only if it leaves it.
858
+ try {
859
+ await mirrorLog(
860
+ computer,
861
+ context,
862
+ next,
863
+ settled.status,
864
+ observed.logTail,
865
+ );
866
+ } catch {
867
+ // A mirror that could not be written never withholds an outcome
868
+ // that was read.
869
+ }
870
+ if (action === "logs") {
871
+ return {
872
+ content: observed.logTail || "(no output yet)",
873
+ isError: false,
874
+ };
875
+ }
876
+ return {
877
+ content: JSON.stringify({
878
+ processId,
879
+ status: settled.status,
880
+ ...(settled.exitCode === undefined
881
+ ? {}
882
+ : { exitCode: settled.exitCode }),
883
+ command: held.command,
884
+ startedAt: held.startedAt,
885
+ ...(held.pid === undefined ? {} : { pid: held.pid }),
886
+ logTail: observed.logTail.slice(-4_000),
887
+ ...(settled.status === "unknown"
888
+ ? {
889
+ note: "The Computer this process was launched on is not the one answering now, or its process is gone with no recorded exit. It is not running; treat its outcome as unknown.",
890
+ }
891
+ : {}),
892
+ }),
893
+ isError: false,
894
+ };
895
+ },
896
+ );
897
+ } catch (error) {
898
+ return failure(error);
899
+ }
900
+ };
901
+
902
+ let captureSequence = 0;
903
+
904
+ /**
905
+ * Captures the Bot's own desktop into the Package-declared `screenshots`
906
+ * root.
907
+ *
908
+ * The bytes are written through the Workspace rather than left where
909
+ * `scrot` put them, because "every write to a durable root records its
910
+ * writer": a file a shell left on the Computer reaches object storage
911
+ * `unattributed`, which is data and never provenance. The result the model
912
+ * reads is JSON — where the capture is and exactly which bytes it is — and
913
+ * the image itself travels as an attachment, shown by a model-invocation
914
+ * adapter that can show it and named in the text by one that cannot.
915
+ *
916
+ * Declared read-only: it observes the Computer and changes nothing, so it
917
+ * records no durable intent. It is still refused while a human holds the
918
+ * takeover lease, because during a takeover the screen is theirs.
919
+ */
920
+ const screenshotTool: ToolDefinition = {
921
+ name: "computer_screenshot",
922
+ // The desktop half of the Computer: the shell, the screen, and the
923
+ // processes a shell left running. Offered to an `executor` subagent,
924
+ // which has the full work toolset, and to a `computerUse` one, whose
925
+ // whole job is the desktop; never to `browserUse`, which drives pages
926
+ // and not the box, and never to the two video roles, which have no
927
+ // Computer at all.
928
+ admission: {
929
+ turnTypes: ["chat", "automation", "subagent"],
930
+ subagentRoles: ["executor", "computerUse"],
931
+ },
932
+ idempotent: true,
933
+ description:
934
+ "Capture a PNG of your own desktop on the Computer and file it in your durable screenshots root. Refused while the user has taken control of the Computer.",
935
+ inputSchema: {
936
+ type: "object",
937
+ properties: {},
938
+ additionalProperties: false,
939
+ },
940
+ validate: (input) =>
941
+ input === undefined ||
942
+ input === null ||
943
+ (typeof input === "object" && Object.keys(input).length === 0),
944
+ execute: async (_input, context) => {
945
+ if (!writer) {
946
+ return {
947
+ content:
948
+ "A screenshot is filed under the Turn that took it; this runtime has no Turn to record as its writer",
949
+ isError: true,
950
+ };
951
+ }
952
+ try {
953
+ return await useComputer(
954
+ await open(context.botId, context.sessionId, context.signal),
955
+ async (computer) => {
956
+ if (!computer.screenshot) {
957
+ throw new ComputerError(
958
+ "capability-unavailable",
959
+ "The selected Computer does not support screenshots",
960
+ );
961
+ }
962
+ const workspace = computer.workspace;
963
+ if (!workspace) {
964
+ throw new ComputerError(
965
+ "capability-unavailable",
966
+ "The selected Computer exposes no Workspace to file a screenshot in",
967
+ );
968
+ }
969
+ const captured = await computer.screenshot.capture({
970
+ signal: context.signal,
971
+ effectId: context.effectId,
972
+ });
973
+ const root: WorkspaceRootV1 = {
974
+ kind: "package-declared",
975
+ userId,
976
+ packageId: "computer",
977
+ rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
978
+ };
979
+ const botKey = computerBotPathKeyV1(context.botId);
980
+ captureSequence += 1;
981
+ const path: WorkspacePathV1 = {
982
+ root,
983
+ path: `${botKey}/${writer.turnId}-${captureSequence}.png`,
984
+ };
985
+ const botWriter: WorkspaceWriterV1 = {
986
+ kind: "bot",
987
+ botId: context.botId,
988
+ sessionId: writer.sessionId,
989
+ turnId: writer.turnId,
990
+ runId: writer.runId,
991
+ };
992
+ const written = await workspace.write({
993
+ path,
994
+ bytes: captured.bytes,
995
+ writer: botWriter,
996
+ expectedGenerationId: null,
997
+ mediaType: captured.mediaType,
998
+ });
999
+ if (written.status !== "ok") {
1000
+ return {
1001
+ content: `The screenshot could not be filed: ${written.status}: ${written.reason}`,
1002
+ isError: true,
1003
+ };
1004
+ }
1005
+ await prune(workspace, root, botKey, botWriter);
1006
+ const dimensions = pngDimensionsV1(captured.bytes);
1007
+ const attachment: ToolAttachmentV1 = {
1008
+ kind: "image",
1009
+ mediaType: captured.mediaType,
1010
+ workspacePath: path,
1011
+ contentHash: written.generation.contentHash,
1012
+ bytes: written.generation.size,
1013
+ };
1014
+ // The bytes are offered to the resident Session so this Turn's
1015
+ // next model request can show them. They are never recorded:
1016
+ // the event log holds the reference, the Workspace holds the
1017
+ // image.
1018
+ ctx.sessions
1019
+ .get(context.sessionId)
1020
+ ?.offerAttachmentBytes(
1021
+ attachment.contentHash,
1022
+ base64Of(captured.bytes),
1023
+ );
1024
+ return {
1025
+ content: JSON.stringify({
1026
+ path: path.path,
1027
+ rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
1028
+ contentHash: attachment.contentHash,
1029
+ bytes: attachment.bytes,
1030
+ ...(dimensions ?? {}),
1031
+ display: captured.display,
1032
+ capturedAt: captured.capturedAt,
1033
+ }),
1034
+ isError: false,
1035
+ attachments: [attachment],
1036
+ };
1037
+ },
1038
+ );
1039
+ } catch (error) {
1040
+ return failure(error);
1041
+ }
1042
+ },
1043
+ };
1044
+
1045
+ /**
1046
+ * Files one self-check report in the Package-declared `doctor` root.
1047
+ *
1048
+ * Through the Workspace, for the same reason a screenshot is: a file left
1049
+ * on the Computer by a shell reaches object storage `unattributed`, and a
1050
+ * report nobody can attribute is a report nobody can act on. One path per
1051
+ * Bot, overwritten: the log on the Computer is the history, and this is
1052
+ * the last answer, readable while the Computer sleeps.
1053
+ */
1054
+ const fileDoctorReport = async (
1055
+ computer: ComputerHandle,
1056
+ botId: string,
1057
+ report: ComputerDoctorReportV1,
1058
+ ): Promise<string | undefined> => {
1059
+ if (!writer || !computer.workspace) return undefined;
1060
+ const root: WorkspaceRootV1 = {
1061
+ kind: "package-declared",
1062
+ userId,
1063
+ packageId: "computer",
1064
+ rootId: COMPUTER_DOCTOR_ROOT_ID,
1065
+ };
1066
+ const path = `${computerBotPathKeyV1(botId)}/latest.json`;
1067
+ const existing = await computer.workspace.stat({ root, path });
1068
+ const written = await computer.workspace.write({
1069
+ path: { root, path },
1070
+ bytes: new TextEncoder().encode(`${JSON.stringify(report, null, 2)}\n`),
1071
+ writer: {
1072
+ kind: "bot",
1073
+ botId,
1074
+ sessionId: writer.sessionId,
1075
+ turnId: writer.turnId,
1076
+ runId: writer.runId,
1077
+ },
1078
+ expectedGenerationId:
1079
+ existing.status === "ok"
1080
+ ? existing.entry.generation.generationId
1081
+ : null,
1082
+ mediaType: "application/json",
1083
+ });
1084
+ return written.status === "ok" ? path : undefined;
1085
+ };
1086
+
1087
+ /**
1088
+ * The self-check, run once for the Computer this Package instance opened.
1089
+ *
1090
+ * "box-doctor runs at startup and on demand" (parity row 27). Startup here
1091
+ * is the first time this Bot reaches its Computer after this Package
1092
+ * loaded — which is the first Turn after a cold provisioning, and after a
1093
+ * Durable Object eviction as well. Repeating it costs one read-only exec
1094
+ * and no effect, so a second run is waste and never damage.
1095
+ *
1096
+ * Nothing here can fail a Turn: a Computer that cannot answer a self-check
1097
+ * is a Computer the next tool call will report on anyway.
1098
+ */
1099
+ let selfChecked = false;
1100
+ const selfCheck = async (
1101
+ computer: ComputerHandle,
1102
+ botId: string,
1103
+ signal: AbortSignal,
1104
+ ): Promise<void> => {
1105
+ if (selfChecked || !computer.doctor || !writer) return;
1106
+ selfChecked = true;
1107
+ try {
1108
+ const report = await computer.doctor.run({ signal });
1109
+ await fileDoctorReport(computer, botId, report);
1110
+ } catch {
1111
+ // An unreadable self-check is not a reason to refuse the tool call the
1112
+ // Bot actually made.
1113
+ }
1114
+ };
1115
+
1116
+ /**
1117
+ * `computer_doctor` — the Computer's self-check, on demand (row 27).
1118
+ *
1119
+ * Declared read-only: every check reads and none repairs, so it records no
1120
+ * durable intent. It is admitted on every turn type, because a Routine
1121
+ * that finds a Computer misbehaving must be able to say what is wrong with
1122
+ * it, and it is *not* refused under a human takeover — a Computer somebody
1123
+ * has taken over is exactly a Computer somebody is debugging.
1124
+ */
1125
+ const doctorTool: ToolDefinition = {
1126
+ name: "computer_doctor",
1127
+ idempotent: true,
1128
+ // The desktop half of the Computer: the shell, the screen, and the
1129
+ // processes a shell left running. Offered to an `executor` subagent,
1130
+ // which has the full work toolset, and to a `computerUse` one, whose
1131
+ // whole job is the desktop; never to `browserUse`, which drives pages
1132
+ // and not the box, and never to the two video roles, which have no
1133
+ // Computer at all.
1134
+ admission: {
1135
+ turnTypes: ["chat", "automation", "subagent"],
1136
+ subagentRoles: ["executor", "computerUse"],
1137
+ },
1138
+ description:
1139
+ "Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile and what the browser announces itself as, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
1140
+ inputSchema: {
1141
+ type: "object",
1142
+ properties: {},
1143
+ additionalProperties: false,
1144
+ },
1145
+ validate: (input) =>
1146
+ input === undefined ||
1147
+ input === null ||
1148
+ (typeof input === "object" && Object.keys(input).length === 0),
1149
+ execute: async (_input, context) => {
1150
+ try {
1151
+ return await useComputer(
1152
+ await open(context.botId, context.sessionId, context.signal),
1153
+ async (computer) => {
1154
+ if (!computer.doctor) {
1155
+ throw new ComputerError(
1156
+ "capability-unavailable",
1157
+ "The selected Computer does not support a self-check",
1158
+ );
1159
+ }
1160
+ const report = await computer.doctor.run({
1161
+ signal: context.signal,
1162
+ });
1163
+ let path: string | undefined;
1164
+ try {
1165
+ path = await fileDoctorReport(computer, context.botId, report);
1166
+ } catch {
1167
+ // A report that could not be filed is still a report that was
1168
+ // read, and withholding it would hide the very failure it
1169
+ // describes.
1170
+ }
1171
+ return {
1172
+ content: JSON.stringify({
1173
+ ...report,
1174
+ ...(path ? { rootId: COMPUTER_DOCTOR_ROOT_ID, path } : {}),
1175
+ }),
1176
+ isError: false,
1177
+ };
1178
+ },
1179
+ );
1180
+ } catch (error) {
1181
+ return failure(error);
1182
+ }
1183
+ },
1184
+ };
1185
+
1186
+ /**
1187
+ * The three background-process tools.
1188
+ *
1189
+ * `check` and `logs` declare their turn types explicitly — every one of
1190
+ * them — because a Routine must be able to collect the outcome of a job a
1191
+ * chat Turn started, and that has to stay true if this Package ever gains
1192
+ * a manifest ceiling that narrows the default. `stop` is left undeclared,
1193
+ * exactly like `computer_exec`, so ending a process is admitted wherever
1194
+ * starting one is. None of them ends a Turn, and none of them keeps a
1195
+ * Computer awake.
1196
+ */
1197
+ const processCheckTool: ToolDefinition = {
1198
+ name: "computer_process_check",
1199
+ idempotent: true,
1200
+ // The desktop half of the Computer: the shell, the screen, and the
1201
+ // processes a shell left running. Offered to an `executor` subagent,
1202
+ // which has the full work toolset, and to a `computerUse` one, whose
1203
+ // whole job is the desktop; never to `browserUse`, which drives pages
1204
+ // and not the box, and never to the two video roles, which have no
1205
+ // Computer at all.
1206
+ admission: {
1207
+ turnTypes: ["chat", "automation", "subagent"],
1208
+ subagentRoles: ["executor", "computerUse"],
1209
+ },
1210
+ description:
1211
+ "Read the status of a background process started with computer_exec{background:true}. Answers running, exited with its code, or unknown when the Computer that held it is gone.",
1212
+ inputSchema: {
1213
+ type: "object",
1214
+ properties: { processId: { type: "string" } },
1215
+ required: ["processId"],
1216
+ additionalProperties: false,
1217
+ },
1218
+ validate: (input) => decodeProcessId(input) !== undefined,
1219
+ execute: async (input, context) => {
1220
+ const processId = decodeProcessId(input);
1221
+ if (!processId)
1222
+ return { content: "A processId is required", isError: true };
1223
+ return settle(context, processId, "check");
1224
+ },
1225
+ };
1226
+
1227
+ const processLogsTool: ToolDefinition = {
1228
+ name: "computer_process_logs",
1229
+ idempotent: true,
1230
+ // The desktop half of the Computer: the shell, the screen, and the
1231
+ // processes a shell left running. Offered to an `executor` subagent,
1232
+ // which has the full work toolset, and to a `computerUse` one, whose
1233
+ // whole job is the desktop; never to `browserUse`, which drives pages
1234
+ // and not the box, and never to the two video roles, which have no
1235
+ // Computer at all.
1236
+ admission: {
1237
+ turnTypes: ["chat", "automation", "subagent"],
1238
+ subagentRoles: ["executor", "computerUse"],
1239
+ },
1240
+ description:
1241
+ "Read the bounded log of a background process. The log keeps its first and last 128 KiB; the middle of a very long run is dropped.",
1242
+ inputSchema: {
1243
+ type: "object",
1244
+ properties: {
1245
+ processId: { type: "string" },
1246
+ tailBytes: { type: "number", minimum: 1, maximum: 64_000 },
1247
+ },
1248
+ required: ["processId"],
1249
+ additionalProperties: false,
1250
+ },
1251
+ validate: (input) => decodeProcessId(input) !== undefined,
1252
+ execute: async (input, context) => {
1253
+ const processId = decodeProcessId(input);
1254
+ if (!processId)
1255
+ return { content: "A processId is required", isError: true };
1256
+ const tailBytes = record(input)?.tailBytes;
1257
+ return settle(
1258
+ context,
1259
+ processId,
1260
+ "logs",
1261
+ typeof tailBytes === "number" ? tailBytes : undefined,
1262
+ );
1263
+ },
1264
+ };
1265
+
1266
+ const processStopTool: ToolDefinition = {
1267
+ name: "computer_process_stop",
1268
+ // The desktop half of the Computer: the shell, the screen, and the
1269
+ // processes a shell left running. Offered to an `executor` subagent,
1270
+ // which has the full work toolset, and to a `computerUse` one, whose
1271
+ // whole job is the desktop; never to `browserUse`, which drives pages
1272
+ // and not the box, and never to the two video roles, which have no
1273
+ // Computer at all.
1274
+ admission: {
1275
+ turnTypes: ["chat", "automation", "subagent"],
1276
+ subagentRoles: ["executor", "computerUse"],
1277
+ },
1278
+ idempotent: config.idempotentEffects === true,
1279
+ description:
1280
+ "End a background process. Its process group is signalled TERM and then KILL after a grace period.",
1281
+ inputSchema: {
1282
+ type: "object",
1283
+ properties: { processId: { type: "string" } },
1284
+ required: ["processId"],
1285
+ additionalProperties: false,
1286
+ },
1287
+ validate: (input) => decodeProcessId(input) !== undefined,
1288
+ execute: async (input, context) => {
1289
+ const processId = decodeProcessId(input);
1290
+ if (!processId)
1291
+ return { content: "A processId is required", isError: true };
1292
+ return settle(context, processId, "stop");
1293
+ },
1294
+ };
1295
+
1296
+ const browserTool: ToolDefinition = {
1297
+ name: "computer_browser",
1298
+ // Page-level browser control, which `browserUse` exists for.
1299
+ admission: {
1300
+ turnTypes: ["chat", "automation", "subagent"],
1301
+ subagentRoles: ["executor", "browserUse", "computerUse"],
1302
+ },
1303
+ idempotent: config.idempotentEffects === true,
1304
+ description:
1305
+ "Control the browser in the Bot's selected Computer and return an accessibility snapshot.",
1306
+ inputSchema: {
1307
+ type: "object",
1308
+ properties: {
1309
+ action: {
1310
+ type: "string",
1311
+ enum: ["snapshot", "navigate", "click", "fill", "press", "wait"],
1312
+ },
1313
+ url: { type: "string" },
1314
+ role: { type: "string" },
1315
+ name: { type: "string" },
1316
+ label: { type: "string" },
1317
+ text: { type: "string" },
1318
+ key: { type: "string" },
1319
+ exact: { type: "boolean" },
1320
+ milliseconds: { type: "number", minimum: 0, maximum: 30_000 },
1321
+ },
1322
+ required: ["action"],
1323
+ additionalProperties: false,
1324
+ },
1325
+ validate: (input) => decodeBrowser(input) !== undefined,
1326
+ execute: async (input, context) => {
1327
+ const action = decodeBrowser(input);
1328
+ if (!action)
1329
+ return { content: "Invalid browser action", isError: true };
1330
+ try {
1331
+ return await useComputer(
1332
+ await open(context.botId, context.sessionId, context.signal),
1333
+ async (computer) => {
1334
+ if (!computer.browser) {
1335
+ throw new ComputerError(
1336
+ "capability-unavailable",
1337
+ "The selected Computer does not support browser automation",
1338
+ );
1339
+ }
1340
+ const result = await computer.browser.perform(action, {
1341
+ signal: context.signal,
1342
+ effectId: context.effectId,
1343
+ });
1344
+ return {
1345
+ content: result.accessibilitySnapshot,
1346
+ isError: false,
1347
+ };
1348
+ },
1349
+ );
1350
+ } catch (error) {
1351
+ return failure(error);
1352
+ }
1353
+ },
1354
+ };
1355
+
1356
+ return [
1357
+ ctx.tools.register(execTool),
1358
+ ...(writer ? [ctx.tools.register(screenshotTool)] : []),
1359
+ ctx.tools.register(doctorTool),
1360
+ ...(processes && writer
1361
+ ? [
1362
+ ctx.tools.register(processCheckTool),
1363
+ ctx.tools.register(processLogsTool),
1364
+ ctx.tools.register(processStopTool),
1365
+ ]
1366
+ : []),
1367
+ ctx.tools.register(browserTool),
1368
+ // A Turn's first step is where the Turn's sync state begins; a Turn that
1369
+ // never touches the Computer never syncs and never wakes one.
1370
+ ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
1371
+ currentTurn = turn;
1372
+ turnSync.beginTurn(turn);
1373
+ if (controlPrompt?.loadedTurn() !== turn) {
1374
+ await controlPrompt?.refresh(turn, agent.session);
1375
+ }
1376
+ return next();
1377
+ }),
1378
+ // "after a Turn that used the Computer": the Computer is already awake
1379
+ // for this Bot, so the push costs no wake, and a Sprite that paused
1380
+ // mid-Turn answers `unavailable` and the next run finishes the work.
1381
+ ctx.on("agent/turn-stopping", async (agent, turn) => {
1382
+ if (!turnSync.turnUsedTheComputer(turn)) return;
1383
+ let computer;
1384
+ try {
1385
+ computer = await attach(agent.botId, new AbortController().signal);
1386
+ } catch (error) {
1387
+ await turnSync.unavailable(agent.session.id, error);
1388
+ return;
1389
+ }
1390
+ try {
1391
+ await turnSync.afterTurn(computer, agent.session.id);
1392
+ } catch (error) {
1393
+ await turnSync.unavailable(agent.session.id, error);
1394
+ } finally {
1395
+ await computer.close();
1396
+ }
1397
+ }),
1398
+ ctx.systemPrompt.register({
1399
+ id: "persistent-computer",
1400
+ order: 80,
1401
+ render: () =>
1402
+ [
1403
+ "## Persistent Computer",
1404
+ "You share a persistent Linux Computer with your User's other Bots. You have your own directories and desktop on it; the browser profile is shared.",
1405
+ "Use computer_exec to inspect the filesystem before claiming that a path or file exists.",
1406
+ "Use computer_screenshot to see your own desktop; each capture is filed in your durable screenshots root.",
1407
+ "For a job that outlasts this Turn, use computer_exec with background:true and check it later with computer_process_check. Do not poll it in a loop.",
1408
+ "Use computer_doctor when the Computer misbehaves; it reports disk, desktop, sync, and network in one read-only call.",
1409
+ ...(controlPrompt?.current() ? [controlPrompt.current()] : []),
1410
+ "Never invent a directory listing.",
1411
+ ].join("\n"),
1412
+ }),
1413
+ ];
1414
+ };
1415
+ plugin.inject = ["computers", "tools", "systemPrompt", "sessions"];
1416
+ return plugin;
1417
+ }
1418
+
1419
+ export default createComputerAgentPlugin;