mandala-computer-mcp 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
@@ -0,0 +1,1037 @@
1
+ import { z } from 'zod';
2
+ import { CancelledError, isTransientForPoll, MoveRequiredError, NotFoundError, RateLimitError, } from '../errors.js';
3
+ import { describe, guarded, incompleteWarning, json, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
4
+ import * as P from '../paths.js';
5
+ const idArg = {
6
+ computer_id: z
7
+ .string()
8
+ .optional()
9
+ .describe('Which computer. Defaults to the one selected with use_computer.'),
10
+ };
11
+ /**
12
+ * A pause that ends early when the caller gives up.
13
+ *
14
+ * The wait loops check the signal at the top of each turn, so a sleep that
15
+ * ignored it would still hold a cancelled call for its remaining seconds.
16
+ */
17
+ const sleep = (ms, signal) => new Promise((resolve) => {
18
+ if (signal?.aborted)
19
+ return resolve();
20
+ const t = setTimeout(done, ms);
21
+ function done() {
22
+ clearTimeout(t);
23
+ signal?.removeEventListener('abort', done);
24
+ resolve();
25
+ }
26
+ signal?.addEventListener('abort', done, { once: true });
27
+ });
28
+ /** How long these loops leave between polls. */
29
+ const POLL_MS = 2_000;
30
+ /**
31
+ * The same interval, unless the platform asked for longer.
32
+ *
33
+ * A 429 is the one failure that says how long to wait, and
34
+ * {@link isTransientForPoll} now polls through it — so a loop that ignored
35
+ * `Retry-After` and asked again in two seconds would be spending a rate limit
36
+ * to discover it was still rate limited. The floor stays {@link POLL_MS}: the
37
+ * header can say zero, and a poll loop with no interval is a request storm.
38
+ *
39
+ * Only reached from a failed poll, which is why it takes the error rather than
40
+ * living in {@link sleep}: an ordinary turn has nothing to honour.
41
+ */
42
+ const pollDelay = (err) => err instanceof RateLimitError && err.retryAfterMs !== undefined
43
+ ? Math.max(POLL_MS, err.retryAfterMs)
44
+ : POLL_MS;
45
+ /**
46
+ * The answer to a wait the caller ended.
47
+ *
48
+ * `refused`, not `said`: the wait never reached what it was told to wait for,
49
+ * and a caller reading `isError` to decide whether the step worked would
50
+ * otherwise be unable to tell a cancellation from a computer that came up.
51
+ */
52
+ const cancelled = (id, last) => refused(`Cancelled after waiting for ${id}; it was last seen ${last}. Nothing was changed.`);
53
+ /**
54
+ * The moves table, or `undefined` when the platform did not send one.
55
+ *
56
+ * An envelope without a `moves` array is not an account with no moves in it,
57
+ * and that difference is the whole reason this returns `undefined` rather than
58
+ * `[]`. Both callers below read emptiness as a fact about the account: one
59
+ * reports a quiet account, the other concludes the computer was deleted and
60
+ * stops watching a move that is still running. An unreadable body establishes
61
+ * neither.
62
+ */
63
+ const movesOf = (body) => {
64
+ const list = body?.moves;
65
+ if (!Array.isArray(list))
66
+ return undefined;
67
+ // Rows shape-checked, as list_computers and list_snapshots check theirs. Both
68
+ // callers reach straight into a row — `m.computer_id`, and moveLine's field
69
+ // reads — so a `null` in the array threw a TypeError. In the poll that throw
70
+ // landed OUTSIDE the try/catch that exists to say "THE MOVE IS STILL
71
+ // RUNNING", replacing the one sentence that stops a caller concluding a
72
+ // multi-minute disk copy had failed.
73
+ //
74
+ // The COUNT comes back with them, and that is the half a bare filter would
75
+ // get wrong. Both callers read an empty result as a fact about the account —
76
+ // one reports a quiet account, the other concludes the computer was deleted
77
+ // and stops watching a move that is still running — and a row this function
78
+ // could not read establishes neither. Silently dropping it would trade a
79
+ // TypeError for a confident wrong answer, which is the worse of the two.
80
+ // An unreadable ENVELOPE is still `undefined`: a different fact, a different
81
+ // answer, and the one the callers already handle.
82
+ const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
83
+ return { moves, dropped: list.length - moves.length };
84
+ };
85
+ /** What arrived where a list was expected, for a refusal that names it. */
86
+ const shapeOf = (v) => v === undefined ? 'no body at all' : v === null ? 'null' : typeof v;
87
+ /**
88
+ * The resize refusal that is an OFFER, turned into a next step (OPL-3775).
89
+ *
90
+ * The platform's own sentence first, because it is the one that says what will
91
+ * not fit and what moving costs — written for whoever has to agree to it. What
92
+ * this adds is the two things that sentence cannot know: that retrying is
93
+ * pointless, and the name of the tool that takes the offer up.
94
+ *
95
+ * Both halves matter. Without the first, a model reads 409, reads "worth
96
+ * retrying" in every other refusal it has met, and loops. Without the second it
97
+ * has been told a way out exists and has nothing to call — which is the whole
98
+ * defect this closes, and which is worse for a model than for a person, since a
99
+ * person can go and look at the dashboard.
100
+ */
101
+ const moveOffered = (id, err) => refused(err.movePossible
102
+ ? `${err.message}\n\nThis does not clear by itself: retrying the same resize gets the same answer for ` +
103
+ `as long as ${id} is on that host. move_computer applies exactly this size and moves the computer ` +
104
+ `to a host in the region that can run it. Say what that costs before you call it — the computer's ` +
105
+ `disk is copied to different hardware, and it has to be stopped first.`
106
+ : `${err.message}\n\nThis does not clear by itself, and there is nothing to move to: no host in this ` +
107
+ `region can run that size at all. Ask for less RAM.`);
108
+ /** The size a move is applying, for a line a person can read. */
109
+ const moveShape = (m) => [m.cpu && `${m.cpu} vCPU`, m.ram_mb && `${m.ram_mb} MB RAM`, m.disk_gb && `${m.disk_gb} GB disk`]
110
+ .filter(Boolean)
111
+ .join(' · ') || 'no change';
112
+ /** One row of list_moves. */
113
+ const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : ''} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
114
+ /**
115
+ * The sentence in front of a usage report, and the reason this tool does not
116
+ * simply hand back the JSON the way list_sizes does.
117
+ *
118
+ * Two of these fields are caveats on every number beside them, and a model
119
+ * reading a body top to bottom will act on `vcpu_hours` long before it reaches
120
+ * `degraded`. Every figure is a sum across the hypervisors the account's
121
+ * computers are on, so a host that did not contribute leaves a total that is
122
+ * quietly too small rather than an obviously missing row — the one failure mode
123
+ * a spend check must never present as fact. Saying it first is the difference
124
+ * between a caveat that is present and a caveat that is read.
125
+ *
126
+ * The two are kept apart because only one of them clears: `degraded` is a host
127
+ * that could not be reached and comes right when it comes back, `unmetered` is a
128
+ * host running a daemon older than the meter, and telling a caller to wait for
129
+ * that one is advice that never comes true.
130
+ */
131
+ const usageLine = (u) => {
132
+ const t = u.usage ?? {};
133
+ const window = u.from && u.to ? `${u.from} to ${u.to}` : 'this billing period';
134
+ const head = `${t.vcpu_hours ?? 0} vCPU-hours, ${t.run_hours ?? 0} running hours and ` +
135
+ `${t.disk_gb_months ?? 0} GB-months of disk over ${window}.`;
136
+ const short = [
137
+ u.degraded && 'a hypervisor could not be reached (retry — this one clears)',
138
+ u.unmetered &&
139
+ 'a hypervisor is running a daemon older than the meter (waiting will not fix it)',
140
+ ].filter(Boolean);
141
+ if (!short.length)
142
+ return head;
143
+ return (`THESE TOTALS MAY BE TOO LOW: ${short.join(', and ')}. Every figure is a sum across the fleet, so ` +
144
+ `a host that did not answer leaves a short total rather than a missing row — do not reconcile ` +
145
+ `this against an invoice while it says so.\n\n${head}`);
146
+ };
147
+ /**
148
+ * A move that has stopped, read as the four different things it can be.
149
+ *
150
+ * The states are not four flavours of the same outcome and reading them that way
151
+ * is the mistake worth designing against, because the recovery differs and two
152
+ * of them are not failures at all:
153
+ *
154
+ * done the computer is on the new host at the new size. Success.
155
+ * moved the computer IS on another host, at its OLD size. The move landed
156
+ * and the resize did not. Not "the move failed" — saying so would send
157
+ * a caller looking for a machine that is no longer where it was — and
158
+ * recoverable with an ordinary update_computer, because it is now on a
159
+ * host that can run the size.
160
+ * failed nothing happened. The computer is where it was, untouched.
161
+ * lost we stopped watching. It may well have completed; go and look.
162
+ *
163
+ * `moved`, `failed` and `lost` are refusals so that a caller reading `isError`
164
+ * to decide whether its resize happened gets the right answer — and `moved`
165
+ * carries the loudest instruction of the three, because it is the one where the
166
+ * computer has genuinely changed and the caller might not notice.
167
+ */
168
+ const finishedMove = (id, m) => {
169
+ const detail = m.detail ? ` ${m.detail}` : '';
170
+ if (m.state === 'done')
171
+ return said(`${id} moved and is now ${moveShape(m)}.`, m);
172
+ if (m.state === 'moved') {
173
+ return refused(`${id} MOVED to another host but was NOT resized — it is still at its old size.${detail} The move ` +
174
+ `itself is done and does not need repeating; it is now on a host that can run the size, so ` +
175
+ `update_computer resizes it where it is.`, m);
176
+ }
177
+ if (m.state === 'failed') {
178
+ return refused(`${id} was not moved and was not resized — it is where it was, untouched.${detail}`, m);
179
+ }
180
+ return refused(`The move of ${id} stopped being watched, so we cannot say whether it finished.${detail} Read ` +
181
+ `get_computer to see which size it is at now before doing anything else.`, m);
182
+ };
183
+ const POWER_DESCRIPTIONS = {
184
+ start: 'Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second.',
185
+ stop: 'Shut a computer down: the guest is asked, and given time to do it. Discards a saved session if there is one. The disk is kept. `force` pulls the power instead, for a guest that will not come down on its own.',
186
+ suspend: "Write the guest's RAM to disk and give the host its memory back. A pause, not a stop: start_computer resumes the same session.",
187
+ restart: 'Reset the computer. Refused while a session is suspended, since it would have to guess whether you meant to resume or discard it.',
188
+ };
189
+ export const registerComputers = (server, session, opts) => {
190
+ server.registerTool('list_templates', {
191
+ title: 'List templates',
192
+ description: 'The base images a computer can be created from — name, OS, and the default CPU, RAM and disk each one implies.',
193
+ inputSchema: {},
194
+ annotations: { readOnlyHint: true },
195
+ }, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.TEMPLATES))));
196
+ server.registerTool('list_sizes', {
197
+ title: 'List sizes',
198
+ description: `The named sizes a computer can be launched at — each a template plus a CPU/RAM/disk shape. These are the shapes the platform keeps pre-booted, so ${opts.lifecycle ? 'create_computer with a `size`' : 'a create naming a `size`'} is typically answered in about a second where a custom shape boots cold. ` +
199
+ '`allowed` says whether this account’s plan admits a row; when false, `cheapest_plan` names the plan that would.',
200
+ inputSchema: {},
201
+ annotations: { readOnlyHint: true },
202
+ }, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.SIZES))));
203
+ server.registerTool('list_computers', {
204
+ title: 'List computers',
205
+ description: 'Every computer on this account. Desktop credentials are deliberately not included — use get_desktop_url for those.',
206
+ inputSchema: {
207
+ allow_partial: z
208
+ .boolean()
209
+ .optional()
210
+ .describe('Accept a short list when a hypervisor cannot be reached, instead of the 503 the platform answers by default. The answer then says it is short — a short list reads exactly like the missing computers were deleted.'),
211
+ },
212
+ annotations: { readOnlyHint: true },
213
+ }, ({ allow_partial }, extra) => guarded(async () => {
214
+ // listing, not json: with allow_partial the platform will hand over an
215
+ // inventory it knows is short, and says so in X-GC-Incomplete. Reading
216
+ // the body and dropping the header turns "here is part of the fleet"
217
+ // into "here is the fleet".
218
+ const { items, incomplete } = await session.api
219
+ .with(extra.signal)
220
+ .listing(P.COMPUTERS, {
221
+ query: { allow_partial: allow_partial ? 1 : undefined },
222
+ });
223
+ // Checked rather than asserted. `listing<unknown[]>` is a claim about
224
+ // what the platform sends, not a guarantee — a proxy or a future
225
+ // paginated envelope answers with an object, and `.length` on that is
226
+ // `undefined`, which reads as an empty account: the duplicate-create the
227
+ // rest of this handler goes to some length to prevent, arrived at from
228
+ // the other side.
229
+ //
230
+ // An absent body is the same mistake in its quietest form. `listing`
231
+ // returns `undefined` for a 204 or a zero-length 200 — a gateway
232
+ // answering with nothing at all — and `items ?? []` would turn that
233
+ // silence into the very sentence below about an account with no
234
+ // computers in it. The platform sending no inventory is not the
235
+ // platform sending an empty one, and only one of the two is an
236
+ // invitation to create.
237
+ if (!Array.isArray(items)) {
238
+ const got = items === undefined ? 'no body at all' : items === null ? 'null' : typeof items;
239
+ return refused(`GET /computers answered with ${got}, not a list of computers. This is not an empty account — do not create a computer on the strength of it.`, items);
240
+ }
241
+ const malformed = items.filter((item) => item === null || typeof item !== 'object' || Array.isArray(item)).length;
242
+ const list = items.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item));
243
+ const warning = incompleteWarning('computers', incomplete) +
244
+ (malformed
245
+ ? `WARNING: ignored ${malformed} malformed computer entr${malformed === 1 ? 'y' : 'ies'} from the platform.\n\n`
246
+ : '');
247
+ if (!list.length) {
248
+ if (malformed) {
249
+ return refused(`${warning}No valid computers remained. This is not an empty account — do not create a computer on the strength of a malformed listing.`, items);
250
+ }
251
+ // Two different empty answers, and telling them apart is the whole
252
+ // point of reading the header. A workspace-scoped key gets no
253
+ // unreachable placeholder rows at all — the platform withholds them
254
+ // rather than name computers in other workspaces — so header-present
255
+ // with nothing in the array is the ORDINARY shape of an outage for
256
+ // such a key, not a rare one.
257
+ //
258
+ // Saying "no computers yet, create one" there is the duplicate-create
259
+ // this warning exists to prevent: the model is told in one sentence
260
+ // that an unknown number are missing and in the next that the account
261
+ // is empty, and only one of those suggests an action.
262
+ if (incomplete !== null) {
263
+ return said(`${warning}No computers came back from the part of the fleet that answered. This is NOT an empty account — do not create a computer on the strength of it. Retry in a moment.`);
264
+ }
265
+ // Named only when it is there to call. Under MANDALA_NO_LIFECYCLE
266
+ // create_computer is not registered, and this is the one place the
267
+ // name reached the model at RUN time rather than in a description —
268
+ // an invitation, in a sentence, to call a tool that does not exist.
269
+ return said(opts.lifecycle
270
+ ? 'No computers on this account yet. create_computer makes one; list_templates says what from.'
271
+ : 'No computers on this account yet. This server cannot make one — it was started with the lifecycle tools withheld — so one has to be created elsewhere before there is anything here to drive.');
272
+ }
273
+ const lines = list.map((c) => `- ${describe(c)}`).join('\n');
274
+ return said(`${warning}${list.length} computer(s):\n${lines}`, list.map((c) => withoutCredentials(c)));
275
+ }));
276
+ server.registerTool('get_computer', {
277
+ title: 'Get a computer',
278
+ description: 'Everything the platform knows about one computer: status, size, and the screen resolution every click and screenshot is measured in.',
279
+ inputSchema: { ...idArg },
280
+ annotations: { readOnlyHint: true },
281
+ }, ({ computer_id }, extra) => guarded(async () => {
282
+ const id = session.resolve(computer_id);
283
+ const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
284
+ session.noteResolution(id, c.resolution);
285
+ return said(describe(c), withoutCredentials(c));
286
+ }));
287
+ server.registerTool('use_computer', {
288
+ title: 'Select a computer',
289
+ description: 'Bind a computer to this session, so every later call can leave computer_id out. Answers with its status and screen resolution.',
290
+ inputSchema: {
291
+ computer_id: z.string().describe('The id from list_computers.'),
292
+ },
293
+ }, ({ computer_id }, extra) => guarded(async () => {
294
+ const selectionVersion = session.beginSelection(computer_id);
295
+ try {
296
+ // Read it before binding. Binding an id the platform does not recognise
297
+ // would send every subsequent call to a 404 with no clue why, and the
298
+ // read costs one round trip against a session that will make hundreds.
299
+ const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(computer_id)));
300
+ // The id the platform echoed back, not the one that was typed. They can
301
+ // differ — `P.segment` trims before the call, so " vm-1 " reaches the
302
+ // API as vm-1 — and `unbind` and `noteResolution` compare with `===`,
303
+ // so binding the untrimmed form leaves a later delete_computer("vm-1")
304
+ // unable to clear the selection it just destroyed. The other two bind
305
+ // sites already use `c.id`.
306
+ if (!session.bindIfCurrent(c.id ?? computer_id, c.resolution, selectionVersion)) {
307
+ return refused(`${c.id ?? computer_id} was deleted while it was being selected. The session selection was not changed.`);
308
+ }
309
+ return said(`Selected ${describe(c)}. Later calls need no computer_id.` +
310
+ (c.status === 'running'
311
+ ? ''
312
+ : `\n\nIt is ${c.status ?? 'not running'} — start_computer before driving it.`), withoutCredentials(c));
313
+ }
314
+ finally {
315
+ session.endSelection(computer_id);
316
+ }
317
+ }));
318
+ // Power. Not behind the lifecycle gate: a server that may only attach to
319
+ // computers somebody else made still has to be able to bring one up, and a
320
+ // stopped computer refuses every other tool here.
321
+ //
322
+ // Four tools around one request, and stop registered on its own below it:
323
+ // stop is the only power action with a second argument to take, and folding
324
+ // an optional one into the loop would leave the other three advertising a
325
+ // parameter their route does not read.
326
+ const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
327
+ const id = session.resolve(computer_id);
328
+ const c = unwrapComputer(await session.api
329
+ .with(extra.signal)
330
+ .json('POST', P.computerAction(id, action), { query: opts.query }));
331
+ session.noteResolution(id, c.resolution);
332
+ return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
333
+ });
334
+ for (const action of ['start', 'suspend', 'restart']) {
335
+ server.registerTool(`${action}_computer`, {
336
+ title: `${action[0].toUpperCase()}${action.slice(1)} a computer`,
337
+ description: POWER_DESCRIPTIONS[action],
338
+ inputSchema: { ...idArg },
339
+ }, ({ computer_id }, extra) => power(action, computer_id, extra));
340
+ }
341
+ server.registerTool('stop_computer', {
342
+ title: 'Stop a computer',
343
+ description: POWER_DESCRIPTIONS.stop,
344
+ inputSchema: {
345
+ ...idArg,
346
+ force: z
347
+ .boolean()
348
+ .optional()
349
+ .describe('Pull the power instead of asking, the way holding the button in does. Anything the guest had not written to disk is lost, so this is the second attempt and not the first: stop it politely, and reach for `force` when what comes back is a computer still running — a hung X session, a modal "unsaved changes" dialog, or a service that ignores SIGTERM will refuse the polite stop identically every time it is asked.'),
350
+ },
351
+ }, ({ computer_id, force }, extra) => power('stop', computer_id, extra, {
352
+ // The platform's schema for this one is `enum: ['true']` — a string,
353
+ // with no false in it — so an unforced stop omits the parameter rather
354
+ // than sending `force=false`, the way `allow_partial` and
355
+ // `snapshots=delete` are omitted here already.
356
+ query: { force: force ? 'true' : undefined },
357
+ // Said in the answer and not only in the schema, because the two stops
358
+ // are indistinguishable afterwards: both leave a stopped computer with
359
+ // its disk, and only one of them threw away what was in RAM. A
360
+ // transcript that does not say which happened is a transcript nobody
361
+ // can debug the missing work from.
362
+ note: force
363
+ ? '\n\nThe power was pulled rather than asked for: whatever the guest had not written to disk is gone.'
364
+ : undefined,
365
+ }));
366
+ server.registerTool('update_computer', {
367
+ title: 'Rename or resize a computer',
368
+ description: "Change a computer's name, its size, or its idle window. The platform refuses these in combination on purpose — a resize needs the computer stopped and the other two do not, so one request cannot honour both without applying half of it. A SUSPENDED computer counts as stopped for a resize, and its saved desktop cannot survive one: the vCPU count and the memory size are part of the saved state, so it is discarded and the next start is a cold boot. Resume it and finish what is open before resizing, or say so before you do it.",
369
+ inputSchema: {
370
+ ...idArg,
371
+ name: z.string().optional(),
372
+ cpu: z.number().int().min(1).optional().describe('Needs the computer stopped.'),
373
+ ram_mb: z.number().int().min(512).optional().describe('Needs the computer stopped.'),
374
+ disk_gb: z
375
+ .number()
376
+ .int()
377
+ .min(1)
378
+ .optional()
379
+ .describe('Needs the computer stopped. Disks grow only.'),
380
+ idle_suspend_min: z
381
+ .number()
382
+ .int()
383
+ .min(0)
384
+ .nullable()
385
+ .optional()
386
+ .describe("Minutes untouched before the host suspends it. null follows the host's own window; send this on its own."),
387
+ },
388
+ }, ({ computer_id, ...fields }, extra) => guarded(async () => {
389
+ const id = session.resolve(computer_id);
390
+ // `null` is meaningful for idle_suspend_min and must survive the filter;
391
+ // every other absent field is dropped so the platform leaves it alone.
392
+ const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
393
+ if (!Object.keys(body).length) {
394
+ return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min.');
395
+ }
396
+ let c;
397
+ try {
398
+ c = unwrapComputer(await session.api.with(extra.signal).json('PATCH', P.computer(id), { body }));
399
+ }
400
+ catch (err) {
401
+ // The one refusal on this route that is an offer rather than an end
402
+ // (OPL-3775). Caught here and nowhere else because this is the only
403
+ // route that produces it, and because the next step names a tool —
404
+ // which the error class, shared with every embedder, has no business
405
+ // knowing about.
406
+ if (err instanceof MoveRequiredError)
407
+ return moveOffered(id, err);
408
+ throw err;
409
+ }
410
+ session.noteResolution(id, c.resolution);
411
+ return said(describe(c), withoutCredentials(c));
412
+ }));
413
+ server.registerTool('move_computer', {
414
+ title: 'Move a computer to a host that can run a bigger size',
415
+ description: 'Grow a computer past what its current host can run, by moving it to another host in the same region first. Only call this after update_computer has refused a resize and said a move is possible: it is the second half of that refusal and nothing else. THIS MOVES THE MACHINE TO DIFFERENT HARDWARE and copies its disk to get there — say so before you call it. The computer must be STOPPED (suspended is not stopped here: a saved desktop only loads on the host that wrote it, so resume and stop it, or discard the session). One move runs per account at a time. Everything is decided again when this runs, so it can still refuse. Waits for the outcome and reports it; list_moves reads it if the wait runs out.',
416
+ inputSchema: {
417
+ ...idArg,
418
+ // Required, unlike every other field here, and unlike the same argument
419
+ // on update_computer. A move exists to escape a RAM ceiling: the
420
+ // platform fills an omitted ram_mb from the computer's current size and
421
+ // then refuses the move for not needing one, so a call without it can
422
+ // only ever be refused. Requiring it here turns a guaranteed 409 into a
423
+ // schema the model cannot get wrong.
424
+ ram_mb: z
425
+ .number()
426
+ .int()
427
+ .min(512)
428
+ .describe('The size that did not fit. Must be MORE than the computer has now.'),
429
+ cpu: z
430
+ .number()
431
+ .int()
432
+ .min(1)
433
+ .optional()
434
+ .describe('Applied with the move. Omit to leave alone.'),
435
+ disk_gb: z
436
+ .number()
437
+ .int()
438
+ .min(1)
439
+ .optional()
440
+ .describe('Applied with the move, after the copy. Disks grow only.'),
441
+ timeout_s: z
442
+ .number()
443
+ .int()
444
+ .min(5)
445
+ .max(900)
446
+ .default(300)
447
+ .describe('How long to wait for the move to finish before handing back and letting you poll.'),
448
+ },
449
+ }, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s }, extra) => guarded(async () => {
450
+ const id = session.resolve(computer_id);
451
+ const body = {
452
+ ram_mb,
453
+ ...(cpu !== undefined && { cpu }),
454
+ ...(disk_gb !== undefined && { disk_gb }),
455
+ };
456
+ // One deadline for the whole call, armed before the POST rather than
457
+ // after it, for the reason wait_for_computer gives: timeout_s is a
458
+ // promise about when this comes back, and a per-poll timer bounds how
459
+ // often it asks instead.
460
+ const untilDeadline = AbortSignal.timeout(timeout_s * 1000);
461
+ const signal = extra.signal
462
+ ? AbortSignal.any([extra.signal, untilDeadline])
463
+ : untilDeadline;
464
+ const api = session.api.with(signal);
465
+ // The 202. Its body is the move as it stood the moment it was accepted,
466
+ // and it is kept because it is the only description of this move that
467
+ // does not depend on a later read succeeding.
468
+ const started = (await api.json('POST', P.computerAction(id, 'move'), { body }));
469
+ let last = started;
470
+ let blocked;
471
+ while (!untilDeadline.aborted) {
472
+ if (extra.signal?.aborted) {
473
+ return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
474
+ `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
475
+ `got to.`, last);
476
+ }
477
+ let table;
478
+ let raw;
479
+ try {
480
+ raw = await api.json('GET', P.MOVES);
481
+ table = movesOf(raw);
482
+ }
483
+ catch (err) {
484
+ if (extra.signal?.aborted)
485
+ continue;
486
+ if (err instanceof CancelledError) {
487
+ if (untilDeadline.aborted)
488
+ break;
489
+ blocked = err.message;
490
+ await sleep(POLL_MS, signal);
491
+ continue;
492
+ }
493
+ // The poll reads the control plane's own table, so the statuses
494
+ // worth riding out are the ones that mean "ask again" — exactly
495
+ // wait_for_computer's list. Anything else is a real failure, and
496
+ // the move is still running behind it, which a thrown error's
497
+ // handler has no way to say. So it is said here.
498
+ if (!isTransientForPoll(err)) {
499
+ return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
500
+ `was the poll failing, not the move. list_moves says where it got to.`, last);
501
+ }
502
+ blocked = err instanceof Error ? err.message : String(err);
503
+ await sleep(pollDelay(err), signal);
504
+ continue;
505
+ }
506
+ // A table that is not a list is the platform failing to answer, not
507
+ // an answer that the move is gone. It rides out the same way a poll
508
+ // that threw does, so the deadline's sentence says the platform could
509
+ // not be asked rather than claiming a deletion nothing established.
510
+ if (!table) {
511
+ blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
512
+ await sleep(POLL_MS, signal);
513
+ continue;
514
+ }
515
+ blocked = undefined;
516
+ const mine = table.moves.find((m) => m.computer_id === id);
517
+ // A move that is no longer listed is one the platform reaped, and it
518
+ // reaps for one reason: the computer was deleted. Not a state to keep
519
+ // polling for.
520
+ //
521
+ // Unless a row could not be READ, in which case absence is not
522
+ // established: the move may be sitting in the row this poll had to
523
+ // drop. That is a poll that could not be answered rather than an
524
+ // answer, so it rides out exactly as a transient failure does, and
525
+ // the deadline's sentence says the platform could not be asked
526
+ // instead of claiming a deletion nothing showed.
527
+ if (!mine && table.dropped) {
528
+ blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
529
+ await sleep(POLL_MS, signal);
530
+ continue;
531
+ }
532
+ if (!mine) {
533
+ return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
534
+ `list_computers.`, last);
535
+ }
536
+ last = mine;
537
+ if (!mine.live)
538
+ return finishedMove(id, mine);
539
+ await sleep(POLL_MS, signal);
540
+ }
541
+ return refused(blocked
542
+ ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
543
+ `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
544
+ : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
545
+ `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
546
+ }));
547
+ server.registerTool('list_moves', {
548
+ title: 'List moves in progress and their outcomes',
549
+ description: 'Every move on this account: the ones running and the ones that finished in the last day. Read this after move_computer if the wait ran out, and read it when a move is refused because another computer on the account is already being moved — only one runs at a time, and this says which one and how far along. `live` is the flag to poll on.',
550
+ inputSchema: {},
551
+ annotations: { readOnlyHint: true },
552
+ }, (_args, extra) => guarded(async () => {
553
+ const body = await session.api.with(extra.signal).json('GET', P.MOVES);
554
+ const moves = movesOf(body);
555
+ if (!moves) {
556
+ return refused(`GET /moves answered with ${shapeOf(body?.moves)}, not a list of moves. This is not an account with no moves on it — a move you started may still be running.`, body);
557
+ }
558
+ // Said the way list_computers and list_snapshots say it: a dropped row
559
+ // is reported, and a listing left with nothing but dropped rows is a
560
+ // refusal rather than an empty account. "No moves on this account" is
561
+ // an affirmative claim, and it is the one the refusal above exists to
562
+ // avoid making from an unreadable answer.
563
+ const warning = moves.dropped
564
+ ? `WARNING: ignored ${moves.dropped} malformed move entr${moves.dropped === 1 ? 'y' : 'ies'} from the platform.\n\n`
565
+ : '';
566
+ if (!moves.moves.length && moves.dropped) {
567
+ return refused(`${warning}No readable moves remained. This is not an account with no moves on it — a move you started may still be running.`, body);
568
+ }
569
+ if (!moves.moves.length)
570
+ return said('No moves on this account.', []);
571
+ return said(`${warning}${moves.moves.map(moveLine).join('\n')}`, moves.moves);
572
+ }));
573
+ server.registerTool('get_usage', {
574
+ title: 'Read what this account has used',
575
+ description: 'What this account has spent: running hours weighted by cores and memory, the storage it holds, and the per-computer breakdown behind the totals — the same figures the billing page shows. Read it before and after a batch of computers to know what one cost, and read it when asked how much anything has cost. Defaults to the current billing period, which is the window an invoice covers. READ THE FIRST LINE OF THE ANSWER: a hypervisor that could not be reached makes every total too LOW rather than absent, and that line is the only thing that says so.',
576
+ inputSchema: {
577
+ from: z
578
+ .string()
579
+ .optional()
580
+ .describe('Start of the window, RFC 3339 with a time zone — "2026-08-01T00:00:00Z". Omit for the start of the billing period, and send it WITH `to` when you are asking about a period that has closed — `to` on its own is measured from the current period and is refused. A timestamp without a zone is refused rather than guessed at. Records go back 399 days.'),
581
+ to: z
582
+ .string()
583
+ .optional()
584
+ .describe('End of the window, same format. Omit for now. A time in the future is answered as now, and the answer says which instant it used. The window itself may be at most 62 days — every hypervisor replays its ledger a day at a time to answer — so read an older period by naming both bounds rather than by widening this one.'),
585
+ },
586
+ annotations: { readOnlyHint: true },
587
+ }, ({ from, to }, extra) => guarded(async () => {
588
+ const body = (await session.api
589
+ .with(extra.signal)
590
+ .json('GET', P.USAGE, { query: P.usageQuery(from, to) }));
591
+ // `0 vCPU-hours` is a bill, and a missing totals object is not one. The
592
+ // fields inside it still default — a meter that sent no `disk_gb_months`
593
+ // is saying zero — but the object holding them has to have arrived, or
594
+ // this answers with a figure nobody metered in the shape of one somebody
595
+ // did, on the one call whose output is compared against an invoice.
596
+ const totals = body?.usage;
597
+ if (totals === null || typeof totals !== 'object' || Array.isArray(totals)) {
598
+ return refused(`GET /usage answered with ${shapeOf(totals)} where the totals object goes, so there are no figures to report. This is NOT an account that used nothing.`, body);
599
+ }
600
+ return said(usageLine(body), body);
601
+ }));
602
+ server.registerTool('wait_for_computer', {
603
+ title: 'Wait for a computer to be ready',
604
+ description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen.',
605
+ inputSchema: {
606
+ ...idArg,
607
+ until: z
608
+ .enum(['running', 'guest'])
609
+ .default('guest')
610
+ .describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need.'),
611
+ timeout_s: z.number().int().min(5).max(900).default(180),
612
+ },
613
+ }, ({ computer_id, until, timeout_s }, extra) => guarded(async () => {
614
+ const id = session.resolve(computer_id);
615
+ // timeout_s used to gate only the top of the loop, which bounds how
616
+ // often this asks and not how long any one ask may take. Node's fetch
617
+ // has no response deadline of its own beyond undici's five-minute
618
+ // header timeout, so a single stalled poll could hold a wait told to
619
+ // give up in thirty seconds for minutes past its word — and the tool's
620
+ // whole contract is that it comes back when it said it would.
621
+ //
622
+ // One signal for the whole wait rather than one per poll: the deadline
623
+ // is a property of the wait, and arming a fresh timer on every turn
624
+ // would leave hundreds of them live across a fifteen-minute window.
625
+ const untilDeadline = AbortSignal.timeout(timeout_s * 1000);
626
+ const signal = extra.signal
627
+ ? AbortSignal.any([extra.signal, untilDeadline])
628
+ : untilDeadline;
629
+ const api = session.api.with(signal);
630
+ let last = 'unknown';
631
+ // Kept so the give-up message can name it. A hypervisor that was
632
+ // unreachable for the whole window is the single most useful thing to
633
+ // report, and swallowing every transient would end the wait saying only
634
+ // that the status was never seen.
635
+ let blocked;
636
+ while (!untilDeadline.aborted) {
637
+ // The caller giving up ends the wait. The signal aborts the request
638
+ // in flight, but nothing about an aborted request stops the next
639
+ // iteration from starting one — so a cancelled call would go on
640
+ // polling the platform for the rest of its timeout_s, up to fifteen
641
+ // minutes of traffic on behalf of nobody.
642
+ if (extra.signal?.aborted)
643
+ return cancelled(id, last);
644
+ // The status read is exactly as transient-prone as the guest probe
645
+ // below it — a hypervisor that cannot be reached answers 503, which
646
+ // is the ordinary weather of a machine still coming up. Letting that
647
+ // out would abort the one tool whose entire job is to keep asking.
648
+ let c;
649
+ try {
650
+ c = unwrapComputer(await api.json('GET', P.computer(id)));
651
+ }
652
+ catch (err) {
653
+ // The caller's own signal is checked first, and by identity rather
654
+ // than by reading the error: the request is now bound to two
655
+ // deadlines, and only one of them means anybody stopped caring.
656
+ if (extra.signal?.aborted)
657
+ return cancelled(id, last);
658
+ // A body stream can also fail without either signal firing (an
659
+ // undici idle timeout is an AbortError). That is a transport
660
+ // failure, not a cancellation, and is retried below as transient.
661
+ // Only the deadline signal proves the wait's own timer arrived.
662
+ if (err instanceof CancelledError) {
663
+ if (untilDeadline.aborted) {
664
+ blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
665
+ break;
666
+ }
667
+ blocked = err.message;
668
+ await sleep(POLL_MS, signal);
669
+ continue;
670
+ }
671
+ if (!isTransientForPoll(err))
672
+ throw err;
673
+ blocked = err instanceof Error ? err.message : String(err);
674
+ await sleep(pollDelay(err), signal);
675
+ continue;
676
+ }
677
+ blocked = undefined;
678
+ session.noteResolution(id, c.resolution);
679
+ last = c.status ?? 'unknown';
680
+ if (last === 'build-failed') {
681
+ // `refused`, for the reason `cancelled` is: the wait never reached
682
+ // what it was told to wait for, and this one never will. A caller
683
+ // reading `isError` to decide whether to go on would otherwise see
684
+ // a build that failed and a guest that answered as the same result.
685
+ //
686
+ // `build.source` is what the machine was built *from*, not why the
687
+ // build failed — printed bare after "Build failed:" it reads as the
688
+ // reason and names an image instead of a cause. `start_error` is
689
+ // the field that carries a diagnostic, so prefer it and label the
690
+ // source as the source when that is all there is.
691
+ const why = c.start_error
692
+ ? `: ${c.start_error}`
693
+ : c.build?.source
694
+ ? ` (built from ${c.build.source}) — the platform gave no reason`
695
+ : ' — the platform gave no reason';
696
+ return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
697
+ }
698
+ // Neither of the next two resolves on its own, so spinning on either
699
+ // burns the whole timeout waiting for something nobody is going to do.
700
+ if (last === 'suspended') {
701
+ return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
702
+ }
703
+ if (last === 'stopped') {
704
+ return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
705
+ }
706
+ if (last === 'running') {
707
+ if (until === 'running')
708
+ return said(`Running: ${describe(c)}`, withoutCredentials(c));
709
+ // "The guest is up" is not a status the platform reports, so it is
710
+ // asked rather than waited for: a trivial exec either answers, or
711
+ // refuses with the 409 that says the agent is not up yet.
712
+ try {
713
+ await api.send('POST', P.computerAction(id, 'exec'), {
714
+ body: P.execBody({ command: 'true', timeout_s: 5 }),
715
+ });
716
+ return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
717
+ }
718
+ catch (err) {
719
+ // The same two deadlines as the status read above, and for the
720
+ // same reason: this catch used to judge the error alone, so a
721
+ // cancellation during the guest probe left the wait throwing what
722
+ // read as a platform outage instead of saying the caller had
723
+ // hung up. Half the loop knew to check the signal and half did
724
+ // not, which is the worse of the two ways to be inconsistent.
725
+ if (extra.signal?.aborted)
726
+ return cancelled(id, last);
727
+ if (err instanceof CancelledError) {
728
+ if (untilDeadline.aborted) {
729
+ blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
730
+ break;
731
+ }
732
+ blocked = err.message;
733
+ await sleep(POLL_MS, signal);
734
+ continue;
735
+ }
736
+ if (!isTransientForPoll(err))
737
+ throw err;
738
+ // The guest probe's own failure decides this turn's interval, for
739
+ // pollDelay's reason. The ordinary path below keeps POLL_MS.
740
+ await sleep(pollDelay(err), signal);
741
+ continue;
742
+ }
743
+ }
744
+ await sleep(POLL_MS, signal);
745
+ }
746
+ // Also a refusal: the deadline passed without the condition being met,
747
+ // which is the same shape of answer as a cancellation and not the same
748
+ // as success. The message still says to call again, because the state
749
+ // it was waiting on may yet arrive.
750
+ return refused(blocked
751
+ ? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
752
+ : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
753
+ }));
754
+ server.registerTool('get_desktop_url', {
755
+ title: 'Get a link to watch the desktop',
756
+ description: "A URL that opens this computer's live desktop in a browser. Watch-only by default. These are credentials in a link: anyone holding one has that desktop until the computer restarts.",
757
+ inputSchema: {
758
+ ...idArg,
759
+ control: z
760
+ .boolean()
761
+ .default(false)
762
+ .describe('Return the full-control URL instead of the watch-only one. It carries a token that is root-equivalent on that machine — the watch-only socket has input dropped by the platform, not merely hidden by the client.'),
763
+ },
764
+ annotations: { readOnlyHint: true },
765
+ }, ({ computer_id, control }, extra) => guarded(async () => {
766
+ const id = session.resolve(computer_id);
767
+ const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
768
+ const vnc = c.vnc;
769
+ if (!vnc) {
770
+ // `refused`: the caller asked for a URL and there is none. Said as a
771
+ // success, an orchestrator reading `isError` cannot tell a link from
772
+ // the absence of one, and hands the next step a sentence where it
773
+ // expected an address.
774
+ return refused(`No desktop credentials on ${id} right now. The platform omits them when the computer is not running, or when its hypervisor could not be reached — a URL built over nothing looks exactly like a working one until it is used.`);
775
+ }
776
+ // A `vnc` object that is missing the requested key is the same answer as
777
+ // no `vnc` at all, and has to read like it. `JSON.stringify` drops an
778
+ // undefined value rather than recording it, so handing the object
779
+ // straight over would print `{}` underneath a sentence promising full
780
+ // control of the machine — the reader is told a link was given and
781
+ // shown nothing to reconcile that against.
782
+ // Read as strings rather than trusted as them. `vnc` carries a boolean
783
+ // now (`clipboard`, below), so the object is no longer one type — and a
784
+ // number or an object arriving where a URL goes has to read as an absent
785
+ // link rather than be handed on as one, which is what the check below
786
+ // decides.
787
+ const link = (v) => (typeof v === 'string' && v ? v : undefined);
788
+ const links = control
789
+ ? { url: link(vnc.url) }
790
+ : { view_url: link(vnc.view_url), embed_url: link(vnc.embed_url) };
791
+ if (!Object.values(links).some(Boolean)) {
792
+ return refused(`The platform is holding desktop credentials for ${id} but sent no ${control ? 'control' : 'watch-only'} URL among them. ${control ? 'Ask without control: true for the watch-only link.' : 'Try again in a moment, or ask with control: true.'}`);
793
+ }
794
+ // Whether the CLIPBOARD crosses this socket, which the platform now
795
+ // answers (OPL-3870). Three terms this server cannot see — the vdagent
796
+ // channel QEMU was given at its last cold boot, whether the image the
797
+ // computer was built from was verified to carry the agent, and the
798
+ // guest's OS — resolved into one boolean on the same body the links
799
+ // came from, so it costs no second call and no cache.
800
+ //
801
+ // Absent reads as false, deliberately, and not as "unknown": the two
802
+ // ways to be wrong are not symmetric. A false about a working bridge
803
+ // costs the model nothing, since read_clipboard and write_clipboard work
804
+ // there too, while a true about an absent one is the silently dropped
805
+ // paste the field exists to end. mandala-computer-python's `VncConnect`
806
+ // defaults it the same way and for the same reason.
807
+ const bridged = vnc.clipboard === true;
808
+ const socket = bridged
809
+ ? 'THE CLIPBOARD CROSSES THIS SOCKET on this computer — the platform says so, so a noVNC ' +
810
+ 'client that negotiates the extended cut text pseudo-encoding copies and pastes over this ' +
811
+ 'link on its own and nothing here has to be called. That is the transport being OPEN ' +
812
+ 'rather than a paste being guaranteed: the FIRST paste of a session is often dropped, ' +
813
+ 'because the guest PULLS the text and the agent inside it may not own the selection yet — ' +
814
+ "send it again — and a browser will not hand the guest's clipboard back without focus and " +
815
+ 'permission. It is also what was PROVISIONED rather than a live check. Somebody with root ' +
816
+ 'in that guest can stop or remove the agent afterwards and this answer does not move, so ' +
817
+ 'treat it as stale after anything that modified the guest, and fall back to read_clipboard ' +
818
+ 'and write_clipboard — which work there too, and do not fight over the selection, because ' +
819
+ 'they write the same one the agent then offers onward.'
820
+ : 'THE CLIPBOARD DOES NOT CROSS THIS SOCKET on this computer — the platform says so. Text ' +
821
+ 'pasted into it reaches QEMU and stops, silently, with nothing to catch, so do not ask a ' +
822
+ 'person to paste into this desktop. read_clipboard and write_clipboard are the answer and ' +
823
+ 'need none of the hardware. If you want the socket half anyway, it has two halves and they ' +
824
+ 'are acquired separately. The CHANNEL comes from a COLD start — stop_computer then ' +
825
+ 'start_computer, or restart_computer on a computer that is already stopped, which starts ' +
826
+ 'it; restart_computer on a RUNNING one does NOT do it, because that resets the guest ' +
827
+ 'rather than rebuilding the machine QEMU was given, and a resumed or snapshot-restored ' +
828
+ 'session keeps the topology of the capture it came from. The AGENT comes from the IMAGE ' +
829
+ 'the computer was created from, and nothing moves an existing computer onto a newer one: ' +
830
+ 'installing spice-vdagent in the guest may make a paste work but does not change this ' +
831
+ 'answer, an image the platform has not verified reads the same way even where the agent is ' +
832
+ 'present, and a Windows guest never has it whatever the hardware says. So if you cold-start ' +
833
+ 'for this, the order is stop_computer, start_computer, wait_for_computer until the guest is ' +
834
+ 'up, then get_desktop_url again to read the new answer — and test with a sentinel string ' +
835
+ 'rather than with text you cannot afford to lose. This link keeps working across that: a ' +
836
+ 'stop and a start do NOT reissue the credentials, and restart_computer is the only thing ' +
837
+ 'that does, so use restart_computer on a stopped computer when you want the cold boot AND ' +
838
+ 'a fresh credential. It is refused while a session is suspended, and a computer starting ' +
839
+ 'for the FIRST time may load the boot capture it was created from instead, which carries ' +
840
+ 'that capture topology.';
841
+ return control
842
+ ? said('Full control — keyboard and pointer. Treat this link as a password for that ' +
843
+ 'desktop. Of the tools here, restart_computer is the only one that ends it: a stop ' +
844
+ 'and a start leave it working, so a restart is what revokes one that has leaked.\n\n' +
845
+ 'TO MOVE TEXT, use read_clipboard and write_clipboard. They need nothing of the ' +
846
+ 'hardware, but they require a Linux desktop image with xclip installed. An older or ' +
847
+ 'custom image without xclip gets a permanent 400 from both tools; changing runtime ' +
848
+ 'state or retrying cannot fix that image dependency. Where it is present, prefer the ' +
849
+ 'tools, and do not start by asking a person to paste into the desktop. They are ' +
850
+ 'refused outright on Windows. Do NOT reach for xclip through exec instead: exec runs a login shell, so ' +
851
+ "the guest user's profile prints onto the same output your command does, ahead of it, " +
852
+ 'which corrupts a read you are trying to parse; and a write through exec has to leave ' +
853
+ 'xclip resident, redirect its output, travel base64 to survive an apostrophe, and then ' +
854
+ 'be polled for, because being granted an X selection is asynchronous and a detached ' +
855
+ 'xclip gives up its exit status. write_clipboard does all of that in one call and ' +
856
+ 'confirms the selection was taken before it answers.\n\n' +
857
+ socket, links)
858
+ : said('Watch-only. The platform drops input on this socket, so it is safe to hand to ' +
859
+ "somebody. The guest's clipboard does not come back over it either, and that is " +
860
+ 'enforced rather than asked for: the daemon takes the clipboard capability out of the ' +
861
+ 'connection as it is negotiated, so a patched client gains nothing by asking. What the ' +
862
+ 'person at the desktop COPIES does not reach whoever holds this link — though the ' +
863
+ 'screen still does, so a password visible on it is not protected by this. The ' +
864
+ "platform's own clipboard answer for a watch-only link is therefore always no, " +
865
+ 'whatever the computer itself can do: it describes the socket you were handed rather ' +
866
+ 'than the machine, so ask with control: true before concluding anything about the ' +
867
+ 'computer.', links);
868
+ }));
869
+ // Making and destroying machines. Registered by default — a one-line install
870
+ // that cannot produce a desktop is not much of a demo — and absent rather
871
+ // than present-and-refusing when an operator turns them off, because a tool a
872
+ // model can see is a tool it will try. See ToolOptions.lifecycle.
873
+ if (!opts.lifecycle)
874
+ return;
875
+ server.registerTool('create_computer', {
876
+ title: 'Create a computer',
877
+ description: 'Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account.',
878
+ inputSchema: {
879
+ name: z.string().optional().describe('A label. The platform picks one if you do not.'),
880
+ size: z
881
+ .string()
882
+ .optional()
883
+ .describe('A named size from list_sizes, e.g. "large" — the fast path, since these are the shapes the platform keeps pre-booted. It sets template, cpu, ram_mb and disk_gb together, so send it alone or the explicit fields alone, never both.'),
884
+ template: z
885
+ .string()
886
+ .optional()
887
+ .describe('From list_templates, e.g. "base" for Linux/Xfce. Defaults to the platform default.'),
888
+ cpu: z.number().int().min(1).optional(),
889
+ ram_mb: z.number().int().min(512).optional(),
890
+ disk_gb: z
891
+ .number()
892
+ .int()
893
+ .min(1)
894
+ .optional()
895
+ .describe("The template's own disk is a FLOOR, not a default — read it from list_templates. A smaller number is raised to it silently and the account is charged the raised figure, so asking for less than the template needs spends more of the plan's disk pool than the number here suggests and can be refused outright."),
896
+ resolution: z
897
+ .string()
898
+ .optional()
899
+ .describe('WIDTHxHEIGHT or WIDTHxHEIGHTxDEPTH, 640x480 to 3840x2160, even numbers. Create-time only — the display is a QEMU property and there is no route that changes it later. Defaults to 1280x800x24.'),
900
+ start: z.boolean().optional().describe('Boot it immediately. True by default.'),
901
+ },
902
+ annotations: { destructiveHint: false, openWorldHint: true },
903
+ }, (args, extra) => guarded(async () => {
904
+ const c = unwrapComputer(await session.api
905
+ .with(extra.signal)
906
+ .json('POST', P.COMPUTERS, { body: P.createBody(args) }));
907
+ // Selection and the sentence claiming it are the same decision. Bound
908
+ // conditionally and reported unconditionally, a create that came back
909
+ // without an id left this session pointing at whatever it held before
910
+ // while telling the model the new machine was selected — so the next
911
+ // call drove the old computer, or none.
912
+ if (!c.id) {
913
+ return refused(`Created ${describe(c)}, but the platform sent no id back, so nothing was selected and this session is still bound to whatever it was before. The machine may exist and be billable — list_computers will say.`, withoutCredentials(c));
914
+ }
915
+ session.bind(c.id, c.resolution);
916
+ // A create whose guest was made and then would not boot is not an
917
+ // error: the machine exists and is billable, so it comes back stopped
918
+ // with the reason on it. Saying so plainly is the difference between a
919
+ // model retrying the start and a model creating a second computer.
920
+ const note = c.start_error
921
+ ? `Created ${describe(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
922
+ : `Created and selected ${describe(c)}.`;
923
+ return said(note, withoutCredentials(c));
924
+ }));
925
+ server.registerTool('clone_computer', {
926
+ title: 'Clone a computer',
927
+ description: 'Copy a computer to a new one — the fork half of snapshot-and-fork. The copy inherits the resolution, because its disk carries a desktop laid out at that size.',
928
+ inputSchema: {
929
+ ...idArg,
930
+ name: z.string().optional().describe('A name for the copy.'),
931
+ },
932
+ }, ({ computer_id, name }, extra) => guarded(async () => {
933
+ const id = session.resolve(computer_id);
934
+ const c = unwrapComputer(await session.api.with(extra.signal).json('POST', P.computerAction(id, 'clone'), {
935
+ body: name === undefined ? {} : { name },
936
+ }));
937
+ if (!c.id) {
938
+ return refused(`The platform accepted the clone of ${id} but sent no id back, so the copy cannot be identified. It may exist and be billable — list_computers will say. The original stays selected.`, withoutCredentials(c));
939
+ }
940
+ return said(`Cloned ${id} to ${describe(c)}. The original stays selected; use_computer to switch.`, withoutCredentials(c));
941
+ }));
942
+ server.registerTool('delete_computer', {
943
+ title: 'Delete a computer',
944
+ description: 'Destroy a computer and its disk. Irreversible. Its snapshots are kept by default and become orphans, which can still be cloned but not restored. To destroy those too, read snapshot_holdings first and pass its fingerprint as `expect`.',
945
+ inputSchema: {
946
+ computer_id: z
947
+ .string()
948
+ .describe('Required in full, even when one is selected — a delete is not a call to infer a target for.'),
949
+ confirm: z
950
+ .literal(true)
951
+ .describe('Must be true. This destroys the disk and everything on it.'),
952
+ delete_snapshots: z
953
+ .boolean()
954
+ .default(false)
955
+ .describe('Also destroy every snapshot of this computer. Requires `expect`. Opt-in because the wrong answer here is unrecoverable: a snapshot kept by mistake costs storage, one destroyed by mistake costs the disk it was the last copy of.'),
956
+ expect: z
957
+ .string()
958
+ .optional()
959
+ .describe('The fingerprint from snapshot_holdings. The purge is refused unless it still names the same set, so a capture that finished after you looked cannot be swept up in a decision that was never about it.'),
960
+ },
961
+ annotations: { destructiveHint: true, idempotentHint: true },
962
+ }, ({ computer_id, delete_snapshots, expect }, extra) => guarded(async () => {
963
+ // The platform makes `expect` optional, for callers that cannot read the
964
+ // holdings and so were never shown a set to be held to. An MCP caller
965
+ // can read them — snapshot_holdings is right there — so here it is
966
+ // required, and the refusal names the tool that produces it.
967
+ //
968
+ // Not fetched on the caller's behalf, which was the tempting shortcut
969
+ // and is the wrong one. A fingerprint read a millisecond before the
970
+ // delete binds the purge to whatever the set is now, not to what anyone
971
+ // agreed to — and the race checkExpectation exists for is exactly that:
972
+ // a capture that finishes between the decision and the click, then gets
973
+ // destroyed by a confirmation that predates it.
974
+ const fingerprint = expect?.trim() || undefined;
975
+ if (delete_snapshots && !fingerprint) {
976
+ return refused('Refusing to purge snapshots without a fingerprint. Call snapshot_holdings on this computer, ' +
977
+ 'check that the count and size are what you meant to destroy, and pass its fingerprint as `expect`. ' +
978
+ 'Nothing has been deleted.');
979
+ }
980
+ let res;
981
+ try {
982
+ res = await session.api
983
+ .with(extra.signal)
984
+ .send('DELETE', P.computer(computer_id), {
985
+ query: {
986
+ snapshots: delete_snapshots ? 'delete' : undefined,
987
+ expect: delete_snapshots ? fingerprint : undefined,
988
+ },
989
+ });
990
+ }
991
+ catch (err) {
992
+ // A 404 means the computer is not there, which is the state this call
993
+ // was asking for. The platform answers it for an id it cannot scope,
994
+ // and this tool is annotated `idempotentHint`, so the retry after a
995
+ // lost 2xx is one a client is invited to make — and it was the one
996
+ // path that reached `send`, threw, and skipped `unbind` entirely,
997
+ // leaving the session driving a machine that no longer exists. That
998
+ // ghost is the whole reason unbind exists.
999
+ //
1000
+ // Unbound BEFORE the rethrow-or-report decision, because it is true
1001
+ // either way: whatever this answers, the caller must not be left
1002
+ // selected on a computer the platform says is gone.
1003
+ if (!(err instanceof NotFoundError))
1004
+ throw err;
1005
+ session.unbind(computer_id);
1006
+ // Reported as a success rather than an error, and deliberately not as
1007
+ // a plain "Deleted": a caller retrying cannot be told its snapshots
1008
+ // were purged when this call is not what purged them, and the count
1009
+ // is a thing only the response that actually did the work carries.
1010
+ //
1011
+ // It does not claim a deletion happened, either. A 404 is equally the
1012
+ // platform's answer for an id that was never on this account — a typo,
1013
+ // or a computer belonging to somebody else — and "deleted" said over
1014
+ // a mistyped id leaves a caller believing a machine is gone while the
1015
+ // real one keeps running and billing. Both readings are named, and the
1016
+ // one call that settles which is the one to make next.
1017
+ return said(`Nothing was deleted: the platform has no computer with the id ${computer_id} on this account. ` +
1018
+ 'Either it was already destroyed — if this is a retry, the first call is the one that did it, and ' +
1019
+ 'whatever that call did with the snapshots is what happened to them — or the id is not one on this ' +
1020
+ 'account, in which case NO computer of yours has been touched and a real one may still be running ' +
1021
+ 'under the id you meant. list_computers says which of the two this is. The session is no longer ' +
1022
+ 'bound to that id either way.');
1023
+ }
1024
+ session.unbind(computer_id);
1025
+ // A count only when the platform sent one. `?? 0` here would turn "it
1026
+ // did not say" into the affirmative claim that nothing was destroyed —
1027
+ // a false statement about an irreversible act, in the tool that goes to
1028
+ // the most trouble of any here not to misrepresent one.
1029
+ const purged = res?.snapshots_deleted === undefined
1030
+ ? 'its snapshots'
1031
+ : `${res.snapshots_deleted} of its snapshot(s)`;
1032
+ return said(delete_snapshots
1033
+ ? `Deleted ${computer_id} and ${purged}.`
1034
+ : `Deleted ${computer_id}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`);
1035
+ }));
1036
+ };
1037
+ //# sourceMappingURL=computers.js.map