@frockbot/plugin-computer 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,21 +29,21 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.3.3",
33
- "@frockbot/client-ui": "0.3.3",
34
- "@frockbot/computer-core": "0.3.3",
35
- "@frockbot/computer-host-runtime": "0.3.3",
36
- "@frockbot/kernel-agent-loop": "0.3.3",
37
- "@frockbot/kernel-contracts": "0.3.3",
38
- "@frockbot/plugin-prompt": "0.3.3",
39
- "@frockbot/plugin-shell": "0.3.3",
40
- "@frockbot/plugin-tools": "0.3.3",
32
+ "@frockbot/client-core": "0.3.5",
33
+ "@frockbot/client-ui": "0.3.5",
34
+ "@frockbot/computer-core": "0.3.5",
35
+ "@frockbot/computer-host-runtime": "0.3.5",
36
+ "@frockbot/kernel-agent-loop": "0.3.5",
37
+ "@frockbot/kernel-contracts": "0.3.5",
38
+ "@frockbot/plugin-prompt": "0.3.5",
39
+ "@frockbot/plugin-shell": "0.3.5",
40
+ "@frockbot/plugin-tools": "0.3.5",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.3.3",
46
- "@frockbot/plugin-testkit": "0.3.3",
45
+ "@frockbot/plugin-models": "0.3.5",
46
+ "@frockbot/plugin-testkit": "0.3.5",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -256,12 +256,42 @@ describe("computer agent contribution", () => {
256
256
  await expect(
257
257
  execute(harness, "computer_exec", { command: "pwd" }),
258
258
  ).resolves.toEqual({
259
- content: "The user is controlling this Computer; do not retry this Turn",
259
+ content: "held by human:session-1; do not retry this Turn",
260
260
  isError: true,
261
261
  });
262
262
  await harness.dispose();
263
263
  });
264
264
 
265
+ test("an unconfigured deployment offers no Computer tool and no Computer prompt", async () => {
266
+ const harness = await createPluginHarness([
267
+ ComputerRegistry,
268
+ ToolRegistry,
269
+ SystemPromptRegistry,
270
+ SessionStore,
271
+ ]);
272
+ await harness.mount(
273
+ createComputerAgentPlugin({
274
+ userId: "user-1",
275
+ defaultProviderId: "fixture",
276
+ configured: false,
277
+ }),
278
+ );
279
+
280
+ const registered = harness.root.tools.registeredNames?.() ?? [];
281
+ expect(registered.filter((name) => name.startsWith("computer_"))).toEqual(
282
+ [],
283
+ );
284
+ const prompt = await harness.root.systemPrompt.assemble({
285
+ sessionId: "session-1",
286
+ provider: "fixture",
287
+ model: "fixture",
288
+ turnType: "chat",
289
+ });
290
+ expect(prompt.text).not.toContain("Persistent Computer");
291
+ expect(prompt.text).not.toContain("computer_exec");
292
+ await harness.dispose();
293
+ });
294
+
265
295
  test("satisfies plugin package conventions", () => {
266
296
  expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
267
297
  name: "@frockbot/plugin-computer",
package/src/agent.ts CHANGED
@@ -111,6 +111,16 @@ export interface ComputerWriterIdentityV1 {
111
111
  export interface ComputerAgentPluginConfig {
112
112
  userId: string;
113
113
  defaultProviderId: string;
114
+ /**
115
+ * Whether this deployment has a Computer at all.
116
+ *
117
+ * False, and the Package mounts no Computer tool and adds no Computer
118
+ * section to the system prompt: a prompt that promises a persistent Linux
119
+ * Computer where there is none costs the User a Turn of model spend per
120
+ * question and ends in the model guessing at a remedy. Absent means
121
+ * configured, so a host that does not know keeps the tools.
122
+ */
123
+ configured?: boolean;
114
124
  idempotentEffects?: boolean;
115
125
  writer?: ComputerWriterIdentityV1;
116
126
  /**
@@ -287,9 +297,13 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
287
297
  function failure(error: unknown): { content: string; isError: true } {
288
298
  if (error instanceof ComputerError) {
289
299
  if (error.code === "human-control-active") {
300
+ // The holder is named, so a second Bot of the same User — and the User
301
+ // reading the transcript — can tell which session has the desktop.
302
+ const holder = error.message.trim();
290
303
  return {
291
- content:
292
- "The user is controlling this Computer; do not retry this Turn",
304
+ content: holder
305
+ ? `${holder}; do not retry this Turn`
306
+ : "The user is controlling this Computer; do not retry this Turn",
293
307
  isError: true,
294
308
  };
295
309
  }
@@ -534,6 +548,11 @@ export function createComputerAgentPlugin(
534
548
  }
535
549
 
536
550
  const plugin: Plugin.Function = (ctx) => {
551
+ // A deployment with no Computer offers no Computer tool and no Computer
552
+ // prompt. The alternative — tools that always fail — spends a Turn's model
553
+ // budget discovering what this host already knows, and leaves the model
554
+ // inventing a way for the User to fix it.
555
+ if (config.configured === false) return [];
537
556
  // One Computer per User (ADR 0012): the assignment is keyed by the User,
538
557
  // and the Bot attaches to it as a tenant.
539
558
  const identity = { userId };
@@ -783,6 +802,11 @@ export function createComputerAgentPlugin(
783
802
  };
784
803
  }
785
804
  const store = processes;
805
+ // The intent this call wrote, until the launch that follows it settles.
806
+ // Left as `starting` by a launch that threw, it is a record nothing can
807
+ // ever answer for and nothing can forget — a failing Computer would
808
+ // spend the Bot's whole 100-record budget having run nothing at all.
809
+ let unsettled: ComputerProcessRecordV1 | undefined;
786
810
  try {
787
811
  return await useComputer(
788
812
  await open(context.botId, context.sessionId, context.signal),
@@ -812,6 +836,7 @@ export function createComputerAgentPlugin(
812
836
  logPath: "",
813
837
  };
814
838
  await store.record({ ...intent, cwd: "/", logPath: "/" });
839
+ unsettled = { ...intent, cwd: "/", logPath: "/" };
815
840
  const launched = await computer.processes.launch(
816
841
  { processId, command },
817
842
  { signal: context.signal, effectId: context.effectId },
@@ -825,6 +850,7 @@ export function createComputerAgentPlugin(
825
850
  pid: launched.pid,
826
851
  };
827
852
  await store.update(running);
853
+ unsettled = undefined;
828
854
  await noteProcess(context.sessionId, turnOf(context), {
829
855
  processId,
830
856
  action: "launch",
@@ -845,6 +871,18 @@ export function createComputerAgentPlugin(
845
871
  },
846
872
  );
847
873
  } catch (error) {
874
+ if (unsettled) {
875
+ // `unknown`, not deleted: the launch may have started something
876
+ // before it threw, and "recovery can read its outcome or classify it
877
+ // as unknown without repeating it". Terminal, so the record is
878
+ // prunable rather than holding a slot for the life of the Bot.
879
+ try {
880
+ await store.update({ ...unsettled, status: "unknown" });
881
+ } catch {
882
+ // Reconciling the intent is never why a tool call fails; the
883
+ // launch failure below is the answer the model needs.
884
+ }
885
+ }
848
886
  if (error instanceof ComputerProcessLimitError) {
849
887
  return { content: error.message, isError: true };
850
888
  }
package/src/bot.test.ts CHANGED
@@ -4,7 +4,11 @@ import type {
4
4
  ComputerControlLease,
5
5
  ComputerHandle,
6
6
  } from "@frockbot/computer-core";
7
- import { computerBotPathKeyV1, ComputerError } from "@frockbot/computer-core";
7
+ import {
8
+ computerBotPathKeyV1,
9
+ COMPUTER_UNCONFIGURED_MESSAGE_V1,
10
+ ComputerError,
11
+ } from "@frockbot/computer-core";
8
12
  import {
9
13
  COMPUTER_CONNECT_DEFERRAL_MS,
10
14
  COMPUTER_CONNECT_START_DELAY_MS,
@@ -127,6 +131,45 @@ function fakeHandle(options: {
127
131
  }
128
132
 
129
133
  describe("Computer Bot Durable Object Contribution", () => {
134
+ test("an unconfigured host rejects every command, connect included", async () => {
135
+ const storage = new MemoryStorage();
136
+ const contribution = createComputerBotBackendContribution({
137
+ storage,
138
+ configured: false,
139
+ providerLabel: "Fake Computer",
140
+ openComputer: () => {
141
+ throw new Error("an unconfigured host must not reach a provider");
142
+ },
143
+ });
144
+
145
+ for (const type of [
146
+ "connect",
147
+ "takeControl",
148
+ "releaseControl",
149
+ "runDoctor",
150
+ ] as const) {
151
+ const receipt = await contribution.execute("user-1", "scout", {
152
+ version: 1,
153
+ commandId: `command-${type}`,
154
+ botId: "scout",
155
+ type,
156
+ });
157
+ expect(receipt).toMatchObject({
158
+ version: 1,
159
+ type,
160
+ status: "rejected",
161
+ failure: COMPUTER_UNCONFIGURED_MESSAGE_V1,
162
+ });
163
+ }
164
+ // Nothing was admitted, so the projection never claims work is under way.
165
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
166
+ phase: "unconfigured",
167
+ message: COMPUTER_UNCONFIGURED_MESSAGE_V1,
168
+ });
169
+ // The refusal is written for the person reading it.
170
+ expect(COMPUTER_UNCONFIGURED_MESSAGE_V1).not.toContain("SPRITES_TOKEN");
171
+ });
172
+
130
173
  test("records provider progress durably and projects its ordered steps", async () => {
131
174
  const storage = new MemoryStorage();
132
175
  let contribution: ReturnType<typeof createComputerBotBackendContribution>;
@@ -703,6 +746,66 @@ describe("Computer Bot Durable Object Contribution", () => {
703
746
  expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(false);
704
747
  });
705
748
 
749
+ test("a release the Computer refuses still drops the durable lease", async () => {
750
+ const storage = new MemoryStorage();
751
+ const now = new Date("2026-09-02T00:00:00.000Z");
752
+ const host = {
753
+ storage,
754
+ configured: true,
755
+ providerLabel: "Fake Computer",
756
+ now: () => now,
757
+ newId: () => "owner-1",
758
+ openComputer: () =>
759
+ Promise.resolve(
760
+ fakeHandle({
761
+ acquire: (ownerId) =>
762
+ Promise.resolve({
763
+ id: ownerId,
764
+ expiresAt: "2026-09-02T00:01:30.000Z",
765
+ }),
766
+ renew: (lease) => Promise.resolve({ id: lease.id, expiresAt: "" }),
767
+ release: () => Promise.reject(new Error("Sprite is unreachable")),
768
+ }),
769
+ ),
770
+ };
771
+ const contribution = createComputerBotBackendContribution(host);
772
+ await contribution.execute(
773
+ "user-1",
774
+ "scout",
775
+ command("takeControl", "take-1"),
776
+ );
777
+ expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(true);
778
+
779
+ const receipt = await contribution.execute(
780
+ "user-1",
781
+ "scout",
782
+ command("releaseControl", "release-1"),
783
+ );
784
+
785
+ // The failure reaches the User rather than being swallowed...
786
+ expect(receipt).toMatchObject({
787
+ status: "rejected",
788
+ failure: "Sprite is unreachable",
789
+ });
790
+ // ...and the User-wide fence is gone, so no heartbeat can renew it and no
791
+ // other Bot of this User is locked out of the Computer forever.
792
+ expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(false);
793
+ const projection = await contribution.read("user-1", "scout");
794
+ expect(projection.controlLease).toBeUndefined();
795
+ expect(projection.phase).toBe("error");
796
+ // A heartbeat that arrives after the failed release has nothing to renew.
797
+ await expect(
798
+ contribution.execute(
799
+ "user-1",
800
+ "scout",
801
+ command("refreshControl", "refresh-1"),
802
+ ),
803
+ ).resolves.toMatchObject({
804
+ status: "rejected",
805
+ failure: "No control lease is active",
806
+ });
807
+ });
808
+
706
809
  test("reclaims a stale lease under a new durable owner", async () => {
707
810
  const storage = new MemoryStorage();
708
811
  let now = new Date("2026-09-02T00:00:00.000Z");
package/src/bot.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // the URL is held in this Contribution instance until a read projects it.
7
7
  import {
8
8
  computerBotPathKeyV1,
9
+ COMPUTER_UNCONFIGURED_MESSAGE_V1,
9
10
  ComputerError,
10
11
  decodeComputerDoctorReportV1,
11
12
  type ComputerConnectionProgressV1,
@@ -413,6 +414,25 @@ function failureText(error: unknown): string {
413
414
  );
414
415
  }
415
416
 
417
+ /**
418
+ * One stored record, or nothing when the codec refuses it.
419
+ *
420
+ * A projection degrades; it does not fail. Losing one record's contribution to
421
+ * the card is better than a Bot whose Computer surface throws forever because
422
+ * a single key holds a shape this version does not know.
423
+ */
424
+ function decoded<T>(
425
+ value: unknown,
426
+ decode: (input: unknown) => T,
427
+ ): T | undefined {
428
+ if (value === undefined) return undefined;
429
+ try {
430
+ return decode(value);
431
+ } catch {
432
+ return undefined;
433
+ }
434
+ }
435
+
416
436
  function isFresh(expiresAt: string, now: Date): boolean {
417
437
  return Date.parse(expiresAt) > now.getTime();
418
438
  }
@@ -685,6 +705,21 @@ export class ComputerBotBackendContribution {
685
705
  "Computer command does not match Bot registration",
686
706
  );
687
707
  }
708
+ // A host with no Computer rejects every command with the same reason,
709
+ // before admission. `connect` in particular must not answer `accepted`:
710
+ // admitting work that provably cannot be done leaves the User watching a
711
+ // projection that will never move, and there is nothing to reconcile
712
+ // later because nothing was ever started.
713
+ if (!this.host.configured) {
714
+ return {
715
+ version: 1,
716
+ commandId: command.commandId,
717
+ type: command.type,
718
+ status: "rejected",
719
+ completedAt: this.now().toISOString(),
720
+ failure: COMPUTER_UNCONFIGURED_MESSAGE_V1,
721
+ };
722
+ }
688
723
  const admitted = await this.admit(userId, command);
689
724
  if ("replay" in admitted) return admitted.replay;
690
725
  if (command.type === "connect") {
@@ -1081,17 +1116,31 @@ export class ComputerBotBackendContribution {
1081
1116
  );
1082
1117
  if (currentValue === undefined) return;
1083
1118
  const current = decodeStoredComputerControlV1(currentValue);
1084
- await this.withComputer(userId, command, async (computer) => {
1085
- if (!computer.control) {
1086
- throw new Error("The selected Computer does not support human control");
1087
- }
1088
- await computer.control.release(
1089
- { id: current.ownerId, expiresAt: current.expiresAt },
1090
- { scope: "desktop-gui", ownerId: current.ownerId },
1091
- { effectId: `computer:${command.commandId}:release-control` },
1092
- );
1093
- });
1094
- await this.host.storage.delete(COMPUTER_CONTROL_RECORD_KEY);
1119
+ try {
1120
+ await this.withComputer(userId, command, async (computer) => {
1121
+ if (!computer.control) {
1122
+ throw new Error(
1123
+ "The selected Computer does not support human control",
1124
+ );
1125
+ }
1126
+ await computer.control.release(
1127
+ { id: current.ownerId, expiresAt: current.expiresAt },
1128
+ { scope: "desktop-gui", ownerId: current.ownerId },
1129
+ { effectId: `computer:${command.commandId}:release-control` },
1130
+ );
1131
+ });
1132
+ } finally {
1133
+ // The durable record goes whatever the provider answered. It is the
1134
+ // User-wide `desktop-gui` fence: left behind by a release the Computer
1135
+ // could not confirm, it is renewed by the client heartbeat forever, and
1136
+ // every Bot of this User loses the Computer with no way back. The
1137
+ // provider's own lease expires on its side; this one has no expiry a
1138
+ // failing host can be trusted to reach. The failure still reaches the
1139
+ // User — it rejects the receipt and records the `error` phase — but it
1140
+ // never becomes a lease nobody can drop.
1141
+ // Prior art: `releaseDesktopLease` in `@frockbot/plugin-subagents`.
1142
+ await this.host.storage.delete(COMPUTER_CONTROL_RECORD_KEY);
1143
+ }
1095
1144
  }
1096
1145
 
1097
1146
  /**
@@ -1279,16 +1328,15 @@ export class ComputerBotBackendContribution {
1279
1328
  this.screenshots(userId, botId),
1280
1329
  this.doctor(userId, botId),
1281
1330
  ]);
1282
- const viewer =
1283
- viewerValue === undefined ? undefined : decodeStoredViewer(viewerValue);
1284
- const control =
1285
- controlValue === undefined
1286
- ? undefined
1287
- : decodeStoredComputerControlV1(controlValue);
1288
- const provider =
1289
- providerValue === undefined
1290
- ? undefined
1291
- : decodeStoredProvider(providerValue);
1331
+ // A record the codec refuses is treated as absent, the way `doctor()`
1332
+ // above and `ComputerProcessStore.list` already do. These three decoders
1333
+ // throw on any unexpected shape — a field a future version adds included —
1334
+ // and this is the read behind the card, the overlay and the poll: one bad
1335
+ // value made `GET /api/bots/:id/computer` throw for that Bot forever, with
1336
+ // nothing in the product able to clear the key.
1337
+ const viewer = decoded(viewerValue, decodeStoredViewer);
1338
+ const control = decoded(controlValue, decodeStoredComputerControlV1);
1339
+ const provider = decoded(providerValue, decodeStoredProvider);
1292
1340
  const liveViewer =
1293
1341
  viewer &&
1294
1342
  this.#liveViewer?.id === viewer.id &&
@@ -1303,7 +1351,7 @@ export class ComputerBotBackendContribution {
1303
1351
  let message: string;
1304
1352
  if (!this.host.configured) {
1305
1353
  phase = "unconfigured";
1306
- message = "No Computer provider is configured for this host";
1354
+ message = COMPUTER_UNCONFIGURED_MESSAGE_V1;
1307
1355
  } else if (provider?.phase === "disconnected") {
1308
1356
  phase = "disconnected";
1309
1357
  message = provider.message;
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { parse } from "@vue/compiler-sfc";
4
4
  import type { ComputerState } from "../shared.js";
5
+ import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.js";
5
6
  import {
6
7
  createComputerViewerActions,
7
8
  decodeComputerViewerFrameMessageV1,
@@ -121,7 +122,19 @@ describe("Computer viewer", () => {
121
122
  const template = parsed.descriptor.template?.content ?? "";
122
123
 
123
124
  expect(cardSource).toContain("Setting up your computer for the first time");
124
- expect(cardSource).toContain("This usually takes 2-3 minutes");
125
+ // An unconfigured card is not a button — clicking it opened a full-screen
126
+ // modal that repeated the sentence already on the card — and no card
127
+ // renders until a Bot exists to have a Computer.
128
+ expect(cardSource).toContain(':disabled="busy || unconfigured"');
129
+ expect(cardSource).toContain('<section v-if="hasBot"');
130
+ // The viewer header names the Bot, not its slug and not the Computer
131
+ // vendor: both are architecture leaking onto the User's screen.
132
+ expect(overlaySource).toContain("{{ botName }}");
133
+ expect(overlaySource).not.toContain("state.providerLabel");
134
+ expect(COMPUTER_COLD_PROVISION_EXPECTATION).toBe(
135
+ "This usually takes 2-3 minutes",
136
+ );
137
+ expect(cardSource).toContain("COMPUTER_COLD_PROVISION_EXPECTATION");
125
138
  expect(cardSource).toContain("Updating your computer");
126
139
  expect(template).toContain('role="progressbar"');
127
140
  expect(template).toContain(':aria-valuenow="progressValueNow"');
@@ -2,7 +2,9 @@
2
2
  import { useRpc } from "@cordisjs/client";
3
3
  import { UiIcon } from "@frockbot/client-ui";
4
4
  import { computed, inject, ref } from "vue";
5
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
5
6
  import { computerKey, type ComputerState } from "../shared.ts";
7
+ import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.ts";
6
8
  import {
7
9
  computerProgressElapsedMs,
8
10
  computerProgressFrame,
@@ -12,7 +14,16 @@ import {
12
14
  const computer = inject(computerKey) ?? useRpc<ComputerState>();
13
15
  const state = computed(() => computer.value);
14
16
  const busy = ref(false);
17
+ // No Bot, no Computer card. The client machine seeds a placeholder projection
18
+ // before any Bot exists, and rendering it told a brand-new User about the
19
+ // Computer of a Bot they had not created yet.
20
+ const shell = inject(frockBotWebDataKey, undefined);
21
+ const hasBot = computed(() => Boolean(shell?.value.activeBotId));
15
22
  const screenshot = computed(() => state.value.screenshots?.[0]);
23
+ // A card that says there is no Computer opens nothing: a full-screen modal
24
+ // repeating the same sentence is a click that costs the User a step and
25
+ // answers nothing.
26
+ const unconfigured = computed(() => state.value.phase === "unconfigured");
16
27
  const opening = computed(
17
28
  () =>
18
29
  state.value.phase === "provisioning" || state.value.phase === "updating",
@@ -32,7 +43,7 @@ const openingHeading = computed(() => {
32
43
  });
33
44
  const setupExpectation = computed(() =>
34
45
  progressRunKind.value === "cold-provision"
35
- ? "This usually takes 2-3 minutes"
46
+ ? COMPUTER_COLD_PROVISION_EXPECTATION
36
47
  : undefined,
37
48
  );
38
49
  const progressPhaseLabel = computed(
@@ -92,11 +103,11 @@ async function open(): Promise<void> {
92
103
  </script>
93
104
 
94
105
  <template>
95
- <section class="computer-card">
106
+ <section v-if="hasBot" class="computer-card">
96
107
  <button
97
108
  type="button"
98
109
  class="computer-screen computer-screen-thumbnail"
99
- :disabled="busy"
110
+ :disabled="busy || unconfigured"
100
111
  aria-label="Open computer in full window"
101
112
  @click="open"
102
113
  >
@@ -10,6 +10,7 @@ import {
10
10
  ref,
11
11
  watch,
12
12
  } from "vue";
13
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
13
14
  import { computerKey, type ComputerState } from "../shared.ts";
14
15
  import { dialogFocusWrapTarget } from "./dialog-focus.ts";
15
16
  import { computerProgressFrame, computerProgressRunKind } from "./progress.ts";
@@ -23,6 +24,12 @@ import {
23
24
  const computer = inject(computerKey) ?? useRpc<ComputerState>();
24
25
  const state = computed(() => computer.value);
25
26
  const busy = ref(false);
27
+ // The Bot as its User named it. A raw slug and an infrastructure vendor are
28
+ // architecture, not identity, and the viewer header is the User's screen.
29
+ const shell = inject(frockBotWebDataKey, undefined);
30
+ const botName = computed(
31
+ () => shell?.value.botSettings?.profile.name ?? "Computer",
32
+ );
26
33
  const confirming = ref(false);
27
34
  const confirmDialog = ref<HTMLElement>();
28
35
  const viewerFrame = ref<HTMLIFrameElement>();
@@ -183,7 +190,9 @@ async function closeViewer(escape = false): Promise<void> {
183
190
  try {
184
191
  await (escape ? actions.escape() : actions.closeViewer());
185
192
  } catch {
186
- // A failed release remains visible in the still-open overlay.
193
+ // The overlay collapses either way: a release the Computer refused is
194
+ // recorded durably and shown on the card, and is never a reason to trap
195
+ // the User in a full-screen viewer.
187
196
  }
188
197
  }
189
198
 
@@ -275,7 +284,7 @@ onBeforeUnmount(() => {
275
284
  <header class="computer-overlay-toolbar">
276
285
  <div class="computer-overlay-identity">
277
286
  <strong>Computer</strong>
278
- <small>{{ state.botId }} · {{ state.providerLabel }}</small>
287
+ <small>{{ botName }}</small>
279
288
  </div>
280
289
  <div class="computer-overlay-actions">
281
290
  <span class="computer-status" :class="`status-${state.phase}`">
@@ -74,6 +74,7 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
74
74
  let hostUpdating = false;
75
75
  let controlHeld = false;
76
76
  let renewFails = false;
77
+ let releaseFails = false;
77
78
  let heldClose: { release: () => void; pending: Promise<void> } | undefined;
78
79
  let state: { value: ComputerState } | undefined;
79
80
  const slots: ClientSlotRegistration[] = [];
@@ -118,7 +119,7 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
118
119
  phase = "human-control";
119
120
  controlHeld = true;
120
121
  }
121
- if (command.type === "releaseControl") {
122
+ if (command.type === "releaseControl" && !releaseFails) {
122
123
  controlHeld = false;
123
124
  if (phase !== "disconnected") phase = "ready";
124
125
  }
@@ -130,13 +131,17 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
130
131
  commandId: command.commandId,
131
132
  type: command.type,
132
133
  status:
133
- command.type === "refreshViewer" && renewFails
134
+ (command.type === "refreshViewer" && renewFails) ||
135
+ (command.type === "releaseControl" && releaseFails)
134
136
  ? "rejected"
135
137
  : "applied",
136
138
  completedAt: "2026-09-02T00:00:00.000Z",
137
139
  ...(command.type === "refreshViewer" && renewFails
138
140
  ? { failure: "viewer session expired" }
139
141
  : {}),
142
+ ...(command.type === "releaseControl" && releaseFails
143
+ ? { failure: "Sprite is unreachable" }
144
+ : {}),
140
145
  };
141
146
  if (command.type === "closeViewer" && heldClose) {
142
147
  return heldClose.pending.then(() => receipt);
@@ -226,6 +231,9 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
226
231
  failRenewal() {
227
232
  renewFails = true;
228
233
  },
234
+ failRelease() {
235
+ releaseFails = true;
236
+ },
229
237
  setUpdating() {
230
238
  phase = "updating";
231
239
  hostUpdating = true;
@@ -285,6 +293,35 @@ describe("hosted Computer provider", () => {
285
293
  mounted.dispose();
286
294
  });
287
295
 
296
+ test("a failed release stops the heartbeat and still closes the viewer", async () => {
297
+ const mounted = mountHostedProvider();
298
+ await flush();
299
+ await mounted.state.openViewer();
300
+ await mounted.state.takeControl();
301
+ expect(mounted.state.phase).toBe("human-control");
302
+
303
+ mounted.failRelease();
304
+ await expect(mounted.state.releaseControl()).rejects.toThrow(
305
+ "Sprite is unreachable",
306
+ );
307
+
308
+ // The projection here keeps reporting the lease — the worst case, a host
309
+ // that cannot drop it. The client must still stop renewing: a takeover
310
+ // whose release failed is one the User can never cancel otherwise.
311
+ const before = postedTypes(mounted.calls).length;
312
+ mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
313
+ await flush();
314
+ expect(postedTypes(mounted.calls).slice(before)).not.toContain(
315
+ "refreshControl",
316
+ );
317
+
318
+ // And the full-screen viewer is closable, rather than trapping the User.
319
+ await mounted.state.closeViewer();
320
+ await flush();
321
+ expect(mounted.state.expanded).toBe(false);
322
+ mounted.dispose();
323
+ });
324
+
288
325
  test("refreshes the viewer only while the overlay is expanded", async () => {
289
326
  const mounted = mountHostedProvider();
290
327
  await flush();
@@ -93,6 +93,8 @@ export function createComputerClientPlugin(
93
93
  let stopStateChannel: (() => void) | undefined;
94
94
  let updateRejoin: unknown;
95
95
  let controlRequest: Promise<void> | undefined;
96
+ /** Set by a release the backend refused; no heartbeat renews after it. */
97
+ let controlAbandoned = false;
96
98
 
97
99
  const state = ref<ComputerState>({
98
100
  ...machine,
@@ -122,7 +124,11 @@ export function createComputerClientPlugin(
122
124
  }
123
125
 
124
126
  function syncControlHeartbeat(): void {
125
- if (machine.phase !== "human-control") {
127
+ // A release the backend could not confirm ends the heartbeat for good.
128
+ // Renewing a lease the User has already asked to drop is how a takeover
129
+ // becomes one that cannot be cancelled; the next explicit `takeControl`
130
+ // is what starts it again.
131
+ if (machine.phase !== "human-control" || controlAbandoned) {
126
132
  stopControlHeartbeat();
127
133
  return;
128
134
  }
@@ -329,6 +335,8 @@ export function createComputerClientPlugin(
329
335
 
330
336
  async function closeViewer(): Promise<void> {
331
337
  if (!machine.expanded) return;
338
+ // A host with no Computer never had a viewer session to close.
339
+ const hadComputer = machine.phase !== "unconfigured";
332
340
  if (controlRequest) {
333
341
  try {
334
342
  await controlRequest;
@@ -337,7 +345,15 @@ export function createComputerClientPlugin(
337
345
  // lease to release before this explicit close finishes.
338
346
  }
339
347
  }
340
- if (machine.takingControl) await releaseControl();
348
+ if (machine.takingControl) {
349
+ try {
350
+ await releaseControl();
351
+ } catch {
352
+ // A release the Computer refused is already recorded durably and
353
+ // has stopped the heartbeat. It is not a reason to keep the User
354
+ // inside a full-screen viewer they asked to leave.
355
+ }
356
+ }
341
357
  // Collapse first. The capture the backend files on close is
342
358
  // opportunistic, and it crosses a service binding to take a screenshot
343
359
  // on the Sprite; an overlay that stayed on screen waiting for that would
@@ -345,6 +361,9 @@ export function createComputerClientPlugin(
345
361
  // than execute, so a refused capture never projects a failure onto a
346
362
  // Computer the User has already stopped watching.
347
363
  apply({ type: "viewer-collapsed" });
364
+ // A viewer that never existed has nothing to close, and the capture the
365
+ // command files is of a session that was never opened.
366
+ if (!hadComputer) return;
348
367
  void (async () => {
349
368
  try {
350
369
  await post("closeViewer");
@@ -357,6 +376,7 @@ export function createComputerClientPlugin(
357
376
 
358
377
  function takeControl(): Promise<void> {
359
378
  if (controlRequest) return controlRequest;
379
+ controlAbandoned = false;
360
380
  const pending = (async () => {
361
381
  if (!machine.viewerUrl) await connect("connect-requested");
362
382
  if (!machine.viewerUrl) return;
@@ -370,7 +390,13 @@ export function createComputerClientPlugin(
370
390
  }
371
391
 
372
392
  async function releaseControl(): Promise<void> {
373
- await execute("releaseControl");
393
+ try {
394
+ await execute("releaseControl");
395
+ } catch (error) {
396
+ controlAbandoned = true;
397
+ syncControlHeartbeat();
398
+ throw error;
399
+ }
374
400
  }
375
401
 
376
402
  async function refreshControl(): Promise<void> {
@@ -404,6 +430,7 @@ export function createComputerClientPlugin(
404
430
  stopControlHeartbeat();
405
431
  stopViewerHeartbeat();
406
432
  stopUpdateRejoin();
433
+ controlAbandoned = false;
407
434
  machine = initialComputerMachineState();
408
435
  Object.assign(state.value, machine);
409
436
  watchStateChannel(selectedBotId);
@@ -102,6 +102,53 @@ describe("the process store", () => {
102
102
  expect((await store.read(record.processId))?.status).toBe("exited");
103
103
  });
104
104
 
105
+ test("prunes finished records rather than disabling background exec forever", async () => {
106
+ const held = storage();
107
+ const store = new ComputerProcessStore(held);
108
+ for (let index = 0; index < COMPUTER_PROCESS_LIMIT_PER_BOT; index += 1) {
109
+ await store.record({
110
+ ...record,
111
+ processId: `p-${index}`,
112
+ // Ordered oldest first, so the prune has an unambiguous victim.
113
+ startedAt: new Date(Date.UTC(2026, 7, 31, 0, 0, index)).toISOString(),
114
+ status: "exited",
115
+ exitCode: 0,
116
+ });
117
+ }
118
+ expect(held.map.size).toBe(COMPUTER_PROCESS_LIMIT_PER_BOT);
119
+
120
+ // The 101st launch is admitted: a finished process's record has already
121
+ // answered everything anyone can ask of it.
122
+ await store.record({ ...record, processId: "p-next", status: "starting" });
123
+
124
+ expect(await store.read("p-next")).toMatchObject({ status: "starting" });
125
+ expect(await store.read("p-0")).toBeUndefined();
126
+ expect(await store.read("p-1")).toMatchObject({ status: "exited" });
127
+ expect(held.map.size).toBe(COMPUTER_PROCESS_LIMIT_PER_BOT);
128
+
129
+ // A record that has not finished is never pruned to make room: it is the
130
+ // only thing that remembers the process exists.
131
+ for (let index = 1; index < COMPUTER_PROCESS_LIMIT_PER_BOT; index += 1) {
132
+ await store.update({
133
+ ...record,
134
+ processId: `p-${index}`,
135
+ status: "running",
136
+ });
137
+ }
138
+ await expect(
139
+ store.record({ ...record, processId: "p-one-too-many" }),
140
+ ).rejects.toBeInstanceOf(ComputerProcessLimitError);
141
+ });
142
+
143
+ test("forgets one record on request", async () => {
144
+ const held = storage();
145
+ const store = new ComputerProcessStore(held);
146
+ await store.record(record);
147
+ await store.delete(record.processId);
148
+ expect(await store.read(record.processId)).toBeUndefined();
149
+ expect(held.map.size).toBe(0);
150
+ });
151
+
105
152
  test("drops a stored value the codec refuses rather than failing the listing", async () => {
106
153
  const held = storage();
107
154
  const store = new ComputerProcessStore(held);
@@ -6,8 +6,8 @@
6
6
  // outlives its Turn, and after a Durable Object eviction the record is the only
7
7
  // thing that knows the process was ever launched.
8
8
  //
9
- // A deep, small module: `record`, `read`, `update`, `list`. Every write goes
10
- // through a decoder, so a value that reaches storage is a value the codec
9
+ // A deep, small module: `record`, `read`, `update`, `delete`, `list`. Every
10
+ // write goes through a decoder, so a value that reaches storage is a value the codec
11
11
  // accepts, and a stored record the codec later refuses is a visible failure
12
12
  // rather than a silently reshaped one.
13
13
  import {
@@ -41,21 +41,76 @@ export class ComputerProcessStore {
41
41
  */
42
42
  async record(intent: ComputerProcessRecordV1): Promise<void> {
43
43
  const decoded = decodeComputerProcessRecordV1(intent);
44
- const held = await this.storage.list<unknown>({
44
+ let held = await this.storage.list<unknown>({
45
45
  prefix: COMPUTER_PROCESS_PREFIX,
46
46
  limit: COMPUTER_PROCESS_LIMIT_PER_BOT + 1,
47
47
  });
48
+ if (
49
+ held.size >= COMPUTER_PROCESS_LIMIT_PER_BOT &&
50
+ !held.has(computerProcessKeyV1(decoded.processId))
51
+ ) {
52
+ // A finished process's record has already answered every question
53
+ // anyone can ask of it. Without this prune the hundredth background
54
+ // command a Bot ever ran disabled `computer_exec{background:true}`
55
+ // permanently: no tool, no command and no UI could forget a record.
56
+ held = await this.prune(held, decoded.processId);
57
+ }
48
58
  if (
49
59
  held.size >= COMPUTER_PROCESS_LIMIT_PER_BOT &&
50
60
  !held.has(computerProcessKeyV1(decoded.processId))
51
61
  ) {
52
62
  throw new ComputerProcessLimitError(
53
- `this Bot already holds ${COMPUTER_PROCESS_LIMIT_PER_BOT} background process records; end or forget one first`,
63
+ `this Bot already has ${COMPUTER_PROCESS_LIMIT_PER_BOT} background processes that have not finished; stop one with computer_process_stop first`,
54
64
  );
55
65
  }
56
66
  await this.storage.put(computerProcessKeyV1(decoded.processId), decoded);
57
67
  }
58
68
 
69
+ /**
70
+ * Drops finished records, oldest first, until the cap has room again.
71
+ *
72
+ * Terminal only: a `starting` or `running` record is the only thing that
73
+ * remembers the process exists, so it is never pruned to make space. An
74
+ * undecodable row goes too — it can answer nothing, and leaving it would let
75
+ * one bad value hold a slot for the life of the Bot.
76
+ */
77
+ private async prune(
78
+ held: Map<string, unknown>,
79
+ incoming: string,
80
+ ): Promise<Map<string, unknown>> {
81
+ const terminal: Array<{ key: string; startedAt: string }> = [];
82
+ for (const [key, value] of held) {
83
+ if (key === computerProcessKeyV1(incoming)) continue;
84
+ try {
85
+ const record = decodeComputerProcessRecordV1(value);
86
+ if (record.status === "exited" || record.status === "unknown") {
87
+ terminal.push({ key, startedAt: record.startedAt });
88
+ }
89
+ } catch (error) {
90
+ if (!(error instanceof ComputerProcessDecodeError)) throw error;
91
+ terminal.push({ key, startedAt: "" });
92
+ }
93
+ }
94
+ terminal.sort((left, right) =>
95
+ left.startedAt.localeCompare(right.startedAt),
96
+ );
97
+ const remaining = new Map(held);
98
+ for (const { key } of terminal) {
99
+ if (remaining.size < COMPUTER_PROCESS_LIMIT_PER_BOT) break;
100
+ await this.storage.delete(key);
101
+ remaining.delete(key);
102
+ }
103
+ return remaining;
104
+ }
105
+
106
+ /**
107
+ * Forgets one record. The process it described is over: the caller has read
108
+ * a terminal status, or is reconciling an intent whose launch never ran.
109
+ */
110
+ async delete(processId: string): Promise<void> {
111
+ await this.storage.delete(computerProcessKeyV1(processId));
112
+ }
113
+
59
114
  async read(processId: string): Promise<ComputerProcessRecordV1 | undefined> {
60
115
  const held = await this.storage.get<unknown>(
61
116
  computerProcessKeyV1(processId),
@@ -53,7 +53,7 @@ interface Computer {
53
53
  workspace: FakeWorkspace;
54
54
  }
55
55
 
56
- function fakeComputer(): Computer {
56
+ function fakeComputer(options: { launchFails?: boolean } = {}): Computer {
57
57
  const calls: string[] = [];
58
58
  const workspace = new FakeWorkspace();
59
59
  const computer: Computer = {
@@ -72,6 +72,9 @@ function fakeComputer(): Computer {
72
72
  processes: {
73
73
  launch: (request) => {
74
74
  calls.push(`launch:${request.processId}:${request.command}`);
75
+ if (options.launchFails) {
76
+ return Promise.reject(new Error("Sprite is unreachable"));
77
+ }
75
78
  return Promise.resolve({
76
79
  pid: 4321,
77
80
  logPath: `/processes/${request.processId}/log`,
@@ -174,6 +177,26 @@ describe("computer_exec with background:true", () => {
174
177
  await harness.dispose();
175
178
  });
176
179
 
180
+ test("settles the intent a failed launch left behind", async () => {
181
+ const computer = fakeComputer({ launchFails: true });
182
+ const held = storage();
183
+ const harness = await mount(computer, held);
184
+
185
+ const result = await call(harness, "computer_exec", {
186
+ command: "npm run build",
187
+ background: true,
188
+ });
189
+
190
+ expect(result).toMatchObject({ isError: true });
191
+ // The record survives — the launch may have started something before it
192
+ // threw — but it is terminal, so the store can prune it. Left `starting`,
193
+ // a failing Computer would burn the Bot's 100-record budget having run
194
+ // nothing at all, and permanently disable background exec.
195
+ const records = [...held.map.values()] as Array<{ status: string }>;
196
+ expect(records).toMatchObject([{ status: "unknown" }]);
197
+ await harness.dispose();
198
+ });
199
+
177
200
  test("is refused where there is nowhere durable to record it", async () => {
178
201
  const computer = fakeComputer();
179
202
  const harness = await createPluginHarness([
package/src/protocol.ts CHANGED
@@ -108,6 +108,20 @@ export type ComputerPhase = (typeof COMPUTER_PHASES)[number];
108
108
 
109
109
  export const COMPUTER_UPDATE_MESSAGE_PREFIX = "Updating the Computer: ";
110
110
 
111
+ /**
112
+ * What the Computer card promises a first-ever cold provision will take, as
113
+ * copy and as the number that copy means.
114
+ *
115
+ * The number is here rather than in the card because a provider's `open`
116
+ * deadline has to outlast it: a wake that takes exactly as long as the product
117
+ * promised must not abort on our own clock and reach the User as a broken
118
+ * Computer. The provider Package's test asserts that relationship, so neither
119
+ * side can drift alone.
120
+ */
121
+ export const COMPUTER_COLD_PROVISION_EXPECTATION_MS = 180_000;
122
+ export const COMPUTER_COLD_PROVISION_EXPECTATION =
123
+ "This usually takes 2-3 minutes";
124
+
111
125
  /** Extracts the provider's update phase label without coupling to a provider. */
112
126
  export function computerUpdateLabelV1(
113
127
  message: string | undefined,