mandala-computer-mcp 0.1.1 → 0.4.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 (72) hide show
  1. package/README.md +139 -24
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +355 -76
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +113 -8
  8. package/dist/cli.js.map +1 -1
  9. package/dist/errors.d.ts +100 -12
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +169 -29
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +45 -4
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +422 -114
  16. package/dist/events.js.map +1 -1
  17. package/dist/format.d.ts +48 -0
  18. package/dist/format.d.ts.map +1 -1
  19. package/dist/format.js +111 -4
  20. package/dist/format.js.map +1 -1
  21. package/dist/http-body.d.ts +17 -0
  22. package/dist/http-body.d.ts.map +1 -0
  23. package/dist/http-body.js +48 -0
  24. package/dist/http-body.js.map +1 -0
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +177 -51
  27. package/dist/http.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/limits.d.ts +17 -0
  33. package/dist/limits.d.ts.map +1 -0
  34. package/dist/limits.js +17 -0
  35. package/dist/limits.js.map +1 -0
  36. package/dist/paths.d.ts +30 -20
  37. package/dist/paths.d.ts.map +1 -1
  38. package/dist/paths.js +89 -25
  39. package/dist/paths.js.map +1 -1
  40. package/dist/poll.d.ts +107 -0
  41. package/dist/poll.d.ts.map +1 -0
  42. package/dist/poll.js +233 -0
  43. package/dist/poll.js.map +1 -0
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +2 -1
  47. package/dist/server.js.map +1 -1
  48. package/dist/tools/agent.d.ts.map +1 -1
  49. package/dist/tools/agent.js +72 -5
  50. package/dist/tools/agent.js.map +1 -1
  51. package/dist/tools/computers.d.ts.map +1 -1
  52. package/dist/tools/computers.js +559 -233
  53. package/dist/tools/computers.js.map +1 -1
  54. package/dist/tools/events.d.ts.map +1 -1
  55. package/dist/tools/events.js +359 -69
  56. package/dist/tools/events.js.map +1 -1
  57. package/dist/tools/guest.d.ts.map +1 -1
  58. package/dist/tools/guest.js +234 -33
  59. package/dist/tools/guest.js.map +1 -1
  60. package/dist/tools/input.d.ts.map +1 -1
  61. package/dist/tools/input.js +92 -8
  62. package/dist/tools/input.js.map +1 -1
  63. package/dist/tools/snapshots.d.ts.map +1 -1
  64. package/dist/tools/snapshots.js +501 -33
  65. package/dist/tools/snapshots.js.map +1 -1
  66. package/dist/tools/templates.d.ts.map +1 -1
  67. package/dist/tools/templates.js +61 -26
  68. package/dist/tools/templates.js.map +1 -1
  69. package/dist/tools/webhooks.d.ts.map +1 -1
  70. package/dist/tools/webhooks.js +116 -17
  71. package/dist/tools/webhooks.js.map +1 -1
  72. package/package.json +3 -2
@@ -1,47 +1,14 @@
1
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';
2
+ import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
3
+ import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
4
4
  import * as P from '../paths.js';
5
+ import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
5
6
  const idArg = {
6
7
  computer_id: z
7
8
  .string()
8
9
  .optional()
9
10
  .describe('Which computer. Defaults to the one selected with use_computer.'),
10
11
  };
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
12
  /**
46
13
  * The answer to a wait the caller ended.
47
14
  *
@@ -79,11 +46,22 @@ const movesOf = (body) => {
79
46
  // TypeError for a confident wrong answer, which is the worse of the two.
80
47
  // An unreadable ENVELOPE is still `undefined`: a different fact, a different
81
48
  // answer, and the one the callers already handle.
82
- const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
49
+ const moves = list.filter((row) => row !== null &&
50
+ typeof row === 'object' &&
51
+ !Array.isArray(row) &&
52
+ typeof row.computer_id === 'string' &&
53
+ Boolean(row.computer_id.trim()));
83
54
  return { moves, dropped: list.length - moves.length };
84
55
  };
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;
56
+ /**
57
+ * What arrived where a list was expected, for a refusal that names it.
58
+ *
59
+ * A list is called a list. `typeof []` is `'object'`, and the usage refusal is
60
+ * reached BY an array — its guard rejects one explicitly — so without this it
61
+ * reports a body that arrived as an "object where the totals object goes",
62
+ * which is a contradiction in the sentence a model has to act on.
63
+ */
64
+ const shapeOf = (v) => v === undefined ? 'no body at all' : v === null ? 'null' : Array.isArray(v) ? 'a list' : typeof v;
87
65
  /**
88
66
  * The resize refusal that is an OFFER, turned into a next step (OPL-3775).
89
67
  *
@@ -109,8 +87,26 @@ const moveOffered = (id, err) => refused(err.movePossible
109
87
  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
88
  .filter(Boolean)
111
89
  .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}` : ''}`;
90
+ /**
91
+ * One row of list_moves, and the three things `live` can say rather than two.
92
+ *
93
+ * `live` is the flag this tool's own description tells a model to poll on, so
94
+ * the one answer it must never give is a confident "not running" about a row
95
+ * that did not say. A truthy test gave exactly that: a row whose flag was absent
96
+ * or was not a boolean read as finished, and a caller polling for the move to end
97
+ * stops there — while a disk is still being copied between two hosts, and while
98
+ * the platform goes on refusing the next move on this account because this one
99
+ * has not finished. The watch loop already treats an unreadable flag as a poll it
100
+ * could not get an answer to; this is the same fact, said in a listing.
101
+ */
102
+ const moveLine = (m) => {
103
+ const liveness = m.live === true
104
+ ? ' (running)'
105
+ : m.live === false
106
+ ? ''
107
+ : ' (LIVENESS UNKNOWN — this row did not say whether the move is still running, so do not read it as finished)';
108
+ return `${m.computer_id}: ${m.state}${liveness} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
109
+ };
114
110
  /**
115
111
  * The sentence in front of a usage report, and the reason this tool does not
116
112
  * simply hand back the JSON the way list_sizes does.
@@ -127,12 +123,34 @@ const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : '
127
123
  * that could not be reached and comes right when it comes back, `unmetered` is a
128
124
  * host running a daemon older than the meter, and telling a caller to wait for
129
125
  * that one is advice that never comes true.
126
+ *
127
+ * EVERY DIMENSION THE PLATFORM PRICES IS IN THE LINE. This sentence is what a
128
+ * model reads before it acts on cost, and a dimension the account is billed on
129
+ * that appears only in the JSON underneath is a figure nobody weighs. Two were
130
+ * missing: `ram_gb_hours`, although the tool's own description promises hours
131
+ * "weighted by cores and memory", and `snapshot_gb_months`, which the API
132
+ * reference calls the unit snapshots are priced in — the figure that explains
133
+ * the bill
134
+ * of an account holding many durable snapshots and running almost nothing.
135
+ *
136
+ * The `_hours` twins of the two `_months` figures are deliberately NOT here.
137
+ * `disk_gb_hours` and `snapshot_gb_hours` are the same integral in the unit the
138
+ * platform does not price, so printing both spellings would double the length
139
+ * of the line to say each thing twice. Add to this line when the platform
140
+ * prices something new, not when it reports something new.
141
+ *
142
+ * Zeroes are printed rather than omitted, like every other figure here: an
143
+ * account that ran nothing weighted by memory metered zero, and a clause that
144
+ * disappears makes that indistinguishable from a platform that did not send the
145
+ * figure at all — the distinction `get_usage` already refuses a missing totals
146
+ * object in order to keep.
130
147
  */
131
148
  const usageLine = (u) => {
132
149
  const t = u.usage ?? {};
133
150
  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}.`;
151
+ const head = `${t.vcpu_hours ?? 0} vCPU-hours, ${t.ram_gb_hours ?? 0} GB-hours of RAM, ` +
152
+ `${t.run_hours ?? 0} running hours, ${t.disk_gb_months ?? 0} GB-months of disk ` +
153
+ `and ${t.snapshot_gb_months ?? 0} GB-months of snapshots over ${window}.`;
136
154
  const short = [
137
155
  u.degraded && 'a hypervisor could not be reached (retry — this one clears)',
138
156
  u.unmetered &&
@@ -180,6 +198,16 @@ const finishedMove = (id, m) => {
180
198
  return refused(`The move of ${id} stopped being watched, so we cannot say whether it finished.${detail} Read ` +
181
199
  `get_computer to see which size it is at now before doing anything else.`, m);
182
200
  };
201
+ /**
202
+ * What follows an acknowledged power action, where anything does. A start is
203
+ * the one whose "ok" is furthest from "usable": the VM is booting or resuming
204
+ * and the desktop inside it answers later, which is the gap the second line of
205
+ * the server instructions exists for.
206
+ */
207
+ const POWER_NEXT = {
208
+ start: ' wait_for_computer with until="guest" is what says when the desktop is answering.',
209
+ restart: ' wait_for_computer with until="guest" is what says when the desktop is back.',
210
+ };
183
211
  const POWER_DESCRIPTIONS = {
184
212
  start: 'Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second.',
185
213
  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.',
@@ -192,7 +220,15 @@ export const registerComputers = (server, session, opts) => {
192
220
  description: 'The base images a computer can be created from — name, OS, and the default CPU, RAM and disk each one implies.',
193
221
  inputSchema: {},
194
222
  annotations: { readOnlyHint: true },
195
- }, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.TEMPLATES))));
223
+ }, (_args, extra) => guarded(async () => {
224
+ const { items, incomplete } = await session.api.with(extra.signal).listing(P.TEMPLATES);
225
+ if (items === undefined || items === null) {
226
+ return refused('The platform did not return a template catalogue. Retry list_templates.');
227
+ }
228
+ return incomplete === null
229
+ ? json(items)
230
+ : said(incompleteWarning('templates', incomplete).trimEnd(), items);
231
+ }));
196
232
  server.registerTool('list_sizes', {
197
233
  title: 'List sizes',
198
234
  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. ` +
@@ -202,15 +238,28 @@ export const registerComputers = (server, session, opts) => {
202
238
  }, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.SIZES))));
203
239
  server.registerTool('list_computers', {
204
240
  title: 'List computers',
205
- description: 'Every computer on this account. Desktop credentials are deliberately not included — use get_desktop_url for those.',
241
+ description: "Every computer on this account that exists or may exist. Desktop credentials are deliberately not included — use get_desktop_url for those. Read `state` before acting on a row: it is the platform's record of whether the machine exists, which is a different question from `status`, what its host says the guest is doing. A row reading `deleting` is on its way out and is not one to bind, start or wait for, and one reading `unreachable` is a row served from the record because the host did not answer — the computer is most likely fine, but nothing only its host knows is on it, `status` included. The two terminal states are not here at all: `deleted` and `lost` come back only when asked for with `state`.",
206
242
  inputSchema: {
207
243
  allow_partial: z
208
244
  .boolean()
209
245
  .optional()
210
246
  .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.'),
247
+ // The control plane's own record, not the guest's (OPL-4554).
248
+ // Read where the listing is assembled and never forwarded to a host, so
249
+ // a filtered listing is as complete as an unfiltered one.
250
+ //
251
+ // Said in the description rather than assumed: omitting this is NOT
252
+ // "every computer". `deleted` and `lost` are terminal and withheld from
253
+ // an unfiltered listing, so this parameter is the only way a caller ever
254
+ // sees one — which is the question somebody asks when a computer they
255
+ // remember is not in the list.
256
+ state: z
257
+ .enum(['live', 'unreachable', 'deleting', 'deleted', 'lost'])
258
+ .optional()
259
+ .describe("Only computers the platform's own record puts in this state — a different question from what the machine is doing, which is `status`. 'live': its host lists it. 'unreachable': its host did not answer THIS request, so the row is the identity on record and not what the machine says; the computer is most likely fine. 'deleting': a delete was sent and not answered yet. 'deleted' and 'lost' are terminal, and asking here is the ONLY way to see one — an unfiltered listing is live, unreachable and deleting, so a computer missing from it may still have a record."),
211
260
  },
212
261
  annotations: { readOnlyHint: true },
213
- }, ({ allow_partial }, extra) => guarded(async () => {
262
+ }, ({ allow_partial, state }, extra) => guarded(async () => {
214
263
  // listing, not json: with allow_partial the platform will hand over an
215
264
  // inventory it knows is short, and says so in X-GC-Incomplete. Reading
216
265
  // the body and dropping the header turns "here is part of the fleet"
@@ -218,7 +267,7 @@ export const registerComputers = (server, session, opts) => {
218
267
  const { items, incomplete } = await session.api
219
268
  .with(extra.signal)
220
269
  .listing(P.COMPUTERS, {
221
- query: { allow_partial: allow_partial ? 1 : undefined },
270
+ query: { allow_partial: allow_partial ? 1 : undefined, state },
222
271
  });
223
272
  // Checked rather than asserted. `listing<unknown[]>` is a claim about
224
273
  // what the platform sends, not a guarantee — a proxy or a future
@@ -262,6 +311,16 @@ export const registerComputers = (server, session, opts) => {
262
311
  if (incomplete !== null) {
263
312
  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
313
  }
314
+ // A filtered listing that came back empty is a fact about the FILTER,
315
+ // and the sentence below is a fact about the account. Saying the
316
+ // account is empty because nothing is `deleted` is the same
317
+ // duplicate-create the incomplete branch above guards against,
318
+ // arrived at from a third direction — and this one is silent, since
319
+ // the platform answers a filter that matches nothing exactly as it
320
+ // answers an account with nothing in it.
321
+ if (state) {
322
+ return said(`${warning}No computers on this account are ${state}. Other computers may exist — this listing asked only for that state. Call list_computers without \`state\` to see the account.`);
323
+ }
265
324
  // Named only when it is there to call. Under MANDALA_NO_LIFECYCLE
266
325
  // create_computer is not registered, and this is the one place the
267
326
  // name reached the model at RUN time rather than in a description —
@@ -290,6 +349,15 @@ export const registerComputers = (server, session, opts) => {
290
349
  inputSchema: {
291
350
  computer_id: z.string().describe('The id from list_computers.'),
292
351
  },
352
+ // No readOnlyHint — this is not a pure read, so the hint would overclaim —
353
+ // but destructiveHint set, because the spec defaults it to TRUE once
354
+ // readOnlyHint is absent and this destroys nothing (OPL-4516). Without it,
355
+ // waiting for a machine to finish booting asks the operator whether
356
+ // destructive updates may be performed.
357
+ //
358
+ // Idempotent: asking again gives the same answer, and a host gating
359
+ // retry-of-a-timed-out-call on this flag should not refuse.
360
+ annotations: { destructiveHint: false, idempotentHint: true },
293
361
  }, ({ computer_id }, extra) => guarded(async () => {
294
362
  const selectionVersion = session.beginSelection(computer_id);
295
363
  try {
@@ -306,10 +374,48 @@ export const registerComputers = (server, session, opts) => {
306
374
  if (!session.bindIfCurrent(c.id ?? computer_id, c.resolution, selectionVersion)) {
307
375
  return refused(`${c.id ?? computer_id} was deleted while it was being selected. The session selection was not changed.`);
308
376
  }
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));
377
+ // The advice has to know what the two waits know, or it sends a model
378
+ // to start a computer that is already starting — the whole of
379
+ // OPL-4631, in the one place a model acts on the sentence soonest.
380
+ //
381
+ // STATUS FIRST, THEN THE POOL, and that order is the correction: the
382
+ // first cut asked only about the pool, so it told a caller to start a
383
+ // build-failed computer, a half-removed one and a build still running
384
+ // — three machines the platform refuses to start at all. A
385
+ // reservation is only ever the difference between "start it" and
386
+ // "wait for it" on a machine that COULD be started.
387
+ const advice = () => {
388
+ const status = c.status ?? 'not running';
389
+ if (status === 'running')
390
+ return '';
391
+ // Terminal, and each with the remedy that actually applies.
392
+ if (status === 'build-failed') {
393
+ return `\n\nIts disk was never finished — delete_computer and build it again. Nothing else clears this.`;
394
+ }
395
+ if (status === 'half-removed') {
396
+ return `\n\nIts files were partly removed: it cannot be started or used again. delete_computer is what clears it.`;
397
+ }
398
+ // A disk copy in progress cannot be started either, and a memory
399
+ // fork resumes itself at the end of one — so both wait, and only
400
+ // the reason differs.
401
+ if (status === 'building') {
402
+ return c.running_ram_mb
403
+ ? `\n\nIt is still building, and its RAM is already reserved — it will come up on its own when the copy finishes. wait_for_computer.`
404
+ : `\n\nIt is still building — wait_for_computer, then start_computer once the copy has finished.`;
405
+ }
406
+ // Stopped or suspended: the one place a start is the right advice,
407
+ // and only when the platform says it is holding nothing.
408
+ if (nothingAdmitted(c)) {
409
+ return `\n\nIt is ${status} — start_computer before driving it.`;
410
+ }
411
+ if (c.running_ram_mb !== undefined) {
412
+ return `\n\nIt is ${status}, and its start has already been admitted — wait_for_computer, not start_computer.`;
413
+ }
414
+ // The pool was not reported, so neither answer is established. Say
415
+ // that, rather than prescribing one of them as though it were.
416
+ return `\n\nIt is ${status}, and this host did not say whether a start is under way — wait_for_computer, which starts nothing, says which it is.`;
417
+ };
418
+ return said(`Selected ${describe(c)}. Later calls need no computer_id.${advice()}`, withoutCredentials(c));
313
419
  }
314
420
  finally {
315
421
  session.endSelection(computer_id);
@@ -325,9 +431,23 @@ export const registerComputers = (server, session, opts) => {
325
431
  // parameter their route does not read.
326
432
  const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
327
433
  const id = session.resolve(computer_id);
328
- const c = unwrapComputer(await session.api
434
+ const body = await session.api
329
435
  .with(extra.signal)
330
- .json('POST', P.computerAction(id, action), { query: opts.query }));
436
+ .json('POST', P.computerAction(id, action), { query: opts.query });
437
+ const c = unwrapComputer(body);
438
+ // What the platform documents for all four is an Ack — `{ok: true}` and
439
+ // nothing else — and that is what it sends. Formatting it as a computer
440
+ // record answered `suspend: (unnamed) · (no id) · unknown`, which the
441
+ // first agent to dogfood the skill (OPL-3914) read as a suspend that had
442
+ // not worked. The id is the one we sent, so say that; a record, should
443
+ // the platform ever start returning one, is described as before.
444
+ //
445
+ // Stripped of credentials on this branch too, and not only on the record
446
+ // one: the test for "is this an Ack" is the absence of an id, and a body
447
+ // with no id is not thereby a body with no `vnc`.
448
+ if (!c.id) {
449
+ return said(`${action}: ok — ${id}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
450
+ }
331
451
  session.noteResolution(id, c.resolution);
332
452
  return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
333
453
  });
@@ -412,7 +532,7 @@ export const registerComputers = (server, session, opts) => {
412
532
  }));
413
533
  server.registerTool('move_computer', {
414
534
  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.',
535
+ 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, reporting progress while it waits so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; list_moves reads the outcome if the wait runs out, and is the answer for a client that cannot.',
416
536
  inputSchema: {
417
537
  ...idArg,
418
538
  // Required, unlike every other field here, and unlike the same argument
@@ -466,83 +586,104 @@ export const registerComputers = (server, session, opts) => {
466
586
  // and it is kept because it is the only description of this move that
467
587
  // does not depend on a later read succeeding.
468
588
  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)
589
+ // The keepalive. A disk crossing between two hosts is minutes, and a
590
+ // tool that says nothing for minutes is one a client cancels — see
591
+ // heartbeat, and OPL-4579 for the wait that proved it.
592
+ const beat = heartbeat(extra, server.server);
593
+ try {
594
+ await beat(`Moving ${id} — the platform has accepted it.`);
595
+ let last = started;
596
+ let blocked;
597
+ while (!untilDeadline.aborted) {
598
+ if (extra.signal?.aborted) {
599
+ return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
600
+ `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
601
+ `got to.`, last);
602
+ }
603
+ let table;
604
+ let raw;
605
+ try {
606
+ raw = await api.json('GET', P.MOVES);
607
+ table = movesOf(raw);
608
+ }
609
+ catch (err) {
610
+ if (extra.signal?.aborted)
611
+ continue;
612
+ if (err instanceof CancelledError) {
613
+ if (untilDeadline.aborted)
614
+ break;
615
+ blocked = err.message;
616
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
617
+ await sleep(POLL_MS, signal);
618
+ continue;
619
+ }
620
+ // The poll reads the control plane's own table, so the statuses
621
+ // worth riding out are the ones that mean "ask again" — exactly
622
+ // wait_for_computer's list. Anything else is a real failure, and
623
+ // the move is still running behind it, which a thrown error's
624
+ // handler has no way to say. So it is said here.
625
+ if (!isTransientForPoll(err)) {
626
+ return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
627
+ `was the poll failing, not the move. list_moves says where it got to.`, last);
628
+ }
629
+ blocked = err instanceof Error ? err.message : String(err);
630
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
631
+ await sleep(pollDelay(err), signal);
485
632
  continue;
486
- if (err instanceof CancelledError) {
487
- if (untilDeadline.aborted)
488
- break;
489
- blocked = err.message;
633
+ }
634
+ // A table that is not a list is the platform failing to answer, not
635
+ // an answer that the move is gone. It rides out the same way a poll
636
+ // that threw does, so the deadline's sentence says the platform could
637
+ // not be asked rather than claiming a deletion nothing established.
638
+ if (!table) {
639
+ blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
640
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
490
641
  await sleep(POLL_MS, signal);
491
642
  continue;
492
643
  }
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);
644
+ blocked = undefined;
645
+ const mine = table.moves.find((m) => m.computer_id === id);
646
+ // A move that is no longer listed is one the platform reaped, and it
647
+ // reaps for one reason: the computer was deleted. Not a state to keep
648
+ // polling for.
649
+ //
650
+ // Unless a row could not be READ, in which case absence is not
651
+ // established: the move may be sitting in the row this poll had to
652
+ // drop. That is a poll that could not be answered rather than an
653
+ // answer, so it rides out exactly as a transient failure does, and
654
+ // the deadline's sentence says the platform could not be asked
655
+ // instead of claiming a deletion nothing showed.
656
+ if (!mine && table.dropped) {
657
+ blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
658
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
659
+ await sleep(POLL_MS, signal);
660
+ continue;
501
661
  }
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`;
662
+ if (!mine) {
663
+ return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
664
+ `list_computers.`, last);
665
+ }
666
+ if (typeof mine.live !== 'boolean') {
667
+ blocked = `GET /moves answered with an unreadable live flag for ${id}`;
668
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
669
+ await sleep(POLL_MS, signal);
670
+ continue;
671
+ }
672
+ last = mine;
673
+ if (!mine.live)
674
+ return finishedMove(id, mine);
675
+ await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
529
676
  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
677
  }
536
- last = mine;
537
- if (!mine.live)
538
- return finishedMove(id, mine);
539
- await sleep(POLL_MS, signal);
678
+ return refused(blocked
679
+ ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
680
+ `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
681
+ : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
682
+ `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
683
+ }
684
+ finally {
685
+ await beat.stop();
540
686
  }
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
687
  }));
547
688
  server.registerTool('list_moves', {
548
689
  title: 'List moves in progress and their outcomes',
@@ -601,7 +742,7 @@ export const registerComputers = (server, session, opts) => {
601
742
  }));
602
743
  server.registerTool('wait_for_computer', {
603
744
  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.',
745
+ 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. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
605
746
  inputSchema: {
606
747
  ...idArg,
607
748
  until: z
@@ -610,6 +751,15 @@ export const registerComputers = (server, session, opts) => {
610
751
  .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
752
  timeout_s: z.number().int().min(5).max(900).default(180),
612
753
  },
754
+ // No readOnlyHint — this is not a pure read, so the hint would overclaim —
755
+ // but destructiveHint set, because the spec defaults it to TRUE once
756
+ // readOnlyHint is absent and this destroys nothing (OPL-4516). Without it,
757
+ // waiting for a machine to finish booting asks the operator whether
758
+ // destructive updates may be performed.
759
+ //
760
+ // Idempotent: asking again gives the same answer, and a host gating
761
+ // retry-of-a-timed-out-call on this flag should not refuse.
762
+ annotations: { destructiveHint: false, idempotentHint: true },
613
763
  }, ({ computer_id, until, timeout_s }, extra) => guarded(async () => {
614
764
  const id = session.resolve(computer_id);
615
765
  // timeout_s used to gate only the top of the loop, which bounds how
@@ -627,129 +777,201 @@ export const registerComputers = (server, session, opts) => {
627
777
  ? AbortSignal.any([extra.signal, untilDeadline])
628
778
  : untilDeadline;
629
779
  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.
780
+ const beat = heartbeat(extra, server.server);
781
+ try {
782
+ let last = 'unknown';
783
+ // Open the progress channel before the first status read. That read
784
+ // has the same network and response deadlines as every later poll,
785
+ // so it can be the whole wait rather than a quick prelude to it.
786
+ await beat(`Waiting for ${id} — asking the platform for its status.`);
787
+ // Kept so the give-up message can name it. A hypervisor that was
788
+ // unreachable for the whole window is the single most useful thing to
789
+ // report, and swallowing every transient would end the wait saying only
790
+ // that the status was never seen.
791
+ let blocked;
792
+ while (!untilDeadline.aborted) {
793
+ // The caller giving up ends the wait. The signal aborts the request
794
+ // in flight, but nothing about an aborted request stops the next
795
+ // iteration from starting one — so a cancelled call would go on
796
+ // polling the platform for the rest of its timeout_s, up to fifteen
797
+ // minutes of traffic on behalf of nobody.
656
798
  if (extra.signal?.aborted)
657
799
  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.
800
+ // The status read is exactly as transient-prone as the guest probe
801
+ // below it — a hypervisor that cannot be reached answers 503, which
802
+ // is the ordinary weather of a machine still coming up. Letting that
803
+ // out would abort the one tool whose entire job is to keep asking.
804
+ let c;
712
805
  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));
806
+ c = unwrapComputer(await api.json('GET', P.computer(id)));
717
807
  }
718
808
  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.
809
+ // The caller's own signal is checked first, and by identity rather
810
+ // than by reading the error: the request is now bound to two
811
+ // deadlines, and only one of them means anybody stopped caring.
725
812
  if (extra.signal?.aborted)
726
813
  return cancelled(id, last);
814
+ // A body stream can also fail without either signal firing (an
815
+ // undici idle timeout is an AbortError). That is a transport
816
+ // failure, not a cancellation, and is retried below as transient.
817
+ // Only the deadline signal proves the wait's own timer arrived.
727
818
  if (err instanceof CancelledError) {
728
819
  if (untilDeadline.aborted) {
729
- blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
820
+ blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
730
821
  break;
731
822
  }
732
823
  blocked = err.message;
824
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
733
825
  await sleep(POLL_MS, signal);
734
826
  continue;
735
827
  }
736
828
  if (!isTransientForPoll(err))
737
829
  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.
830
+ blocked = err instanceof Error ? err.message : String(err);
831
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
740
832
  await sleep(pollDelay(err), signal);
741
833
  continue;
742
834
  }
835
+ blocked = undefined;
836
+ session.noteResolution(id, c.resolution);
837
+ last = c.status ?? 'unknown';
838
+ // ONE beat per turn, and this is not it when a guest probe is about to
839
+ // run. Beating here and again in the probe's failure branch sent two
840
+ // notifications per poll — and because the two lines DIFFER, each read
841
+ // as news to the throttle and neither was ever held, so the steady
842
+ // state of the commonest long wait was twice the per-poll rate the
843
+ // interval exists to avoid (/code-review).
844
+ //
845
+ // Fixed by making the two say the SAME thing rather than by silencing
846
+ // this one, which was the first attempt and dropped the wrong half of
847
+ // the pair (/code-review again). The probe below is the longest call
848
+ // in the loop — an undici header timeout or a proxy 524 can hold it
849
+ // for minutes — so the beat that must survive is the one IN FRONT of
850
+ // it. The 409 branch repeats this line and the throttle holds it; a
851
+ // probe that fails some other way says so, which is news and goes out.
852
+ await beat(until === 'guest' && last === 'running'
853
+ ? `Waiting for ${id} — running; asking the guest.`
854
+ : `Waiting for ${id} — ${last}.`);
855
+ if (last === 'build-failed') {
856
+ // `refused`, for the reason `cancelled` is: the wait never reached
857
+ // what it was told to wait for, and this one never will. A caller
858
+ // reading `isError` to decide whether to go on would otherwise see
859
+ // a build that failed and a guest that answered as the same result.
860
+ //
861
+ // `build.source` is what the machine was built *from*, not why the
862
+ // build failed — printed bare after "Build failed:" it reads as the
863
+ // reason and names an image instead of a cause. `start_error` is
864
+ // the field that carries a diagnostic, so prefer it and label the
865
+ // source as the source when that is all there is.
866
+ const why = c.start_error
867
+ ? `: ${c.start_error}`
868
+ : c.build?.source
869
+ ? ` (built from ${c.build.source}) — the platform gave no reason`
870
+ : ' — the platform gave no reason';
871
+ return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
872
+ }
873
+ // Its files were partly removed and its disk is gone: the platform
874
+ // refuses to start or use it, and only deleting it again clears it.
875
+ // Waiting spent the whole budget and then reported "last seen
876
+ // half-removed", which is the state the caller passed in (Codex
877
+ // review). Not qualified by the pool: nothing can be admitted for a
878
+ // machine with no disk.
879
+ if (last === 'half-removed') {
880
+ return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
881
+ }
882
+ // Neither of the next two resolves on its own, so spinning on either
883
+ // burns the whole timeout waiting for something nobody is going to do.
884
+ //
885
+ // Unless somebody is. `status` is read from the guest process, so a
886
+ // start that has been ADMITTED reads as `stopped` while it boots and
887
+ // as `suspended` while it resumes — the session record is spent only
888
+ // on the way out of a start that worked. Refusing there tells a model
889
+ // to call start_computer on a computer that is already starting, and
890
+ // the obvious next thing it does is start it a second time.
891
+ //
892
+ // nothingAdmitted is the platform's own word for idle, and it is
893
+ // absent-aware: a host that did not answer has not said nothing is
894
+ // coming, so the wait goes on rather than refusing (OPL-4631).
895
+ if (last === 'suspended' && nothingAdmitted(c)) {
896
+ 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));
897
+ }
898
+ if (last === 'stopped' && nothingAdmitted(c)) {
899
+ return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
900
+ }
901
+ if (last === 'running') {
902
+ if (until === 'running')
903
+ return said(`Running: ${describe(c)}`, withoutCredentials(c));
904
+ // "The guest is up" is not a status the platform reports, so it is
905
+ // asked rather than waited for: a trivial exec either answers, or
906
+ // refuses with the 409 that says the agent is not up yet.
907
+ try {
908
+ await api.send('POST', P.computerAction(id, 'exec'), {
909
+ body: P.execBody({ command: 'true', timeout_s: 5 }),
910
+ });
911
+ return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
912
+ }
913
+ catch (err) {
914
+ // The same two deadlines as the status read above, and for the
915
+ // same reason: this catch used to judge the error alone, so a
916
+ // cancellation during the guest probe left the wait throwing what
917
+ // read as a platform outage instead of saying the caller had
918
+ // hung up. Half the loop knew to check the signal and half did
919
+ // not, which is the worse of the two ways to be inconsistent.
920
+ if (extra.signal?.aborted)
921
+ return cancelled(id, last);
922
+ if (err instanceof CancelledError) {
923
+ if (untilDeadline.aborted) {
924
+ blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
925
+ break;
926
+ }
927
+ blocked = err.message;
928
+ await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
929
+ await sleep(POLL_MS, signal);
930
+ continue;
931
+ }
932
+ if (!isTransientForPoll(err))
933
+ throw err;
934
+ // What actually refused, rather than one sentence for every
935
+ // failure. A 409 IS the guest not being up yet — that is the
936
+ // probe working, and it repeats the line beat in front of the
937
+ // probe so the throttle holds it. A 503 is a hypervisor nobody
938
+ // can reach and a transport abort is neither: saying "the guest
939
+ // is not answering" over those tells the person watching the log
940
+ // the one thing this channel exists to get right.
941
+ //
942
+ // And it SETS `blocked`, which it never did — not before this
943
+ // change and not after the first version of it. A wait that spent
944
+ // its whole window failing the probe on 503s gave up saying "was
945
+ // last seen running" and named nothing, while the progress
946
+ // channel had been reporting the hypervisor the entire time: the
947
+ // same contradiction this comment set out to remove, pointed the
948
+ // other way (/code-review). Deliberately not for the 409, where
949
+ // the platform did answer and `blocked` would be a lie about it.
950
+ if (!(err instanceof ConflictError)) {
951
+ blocked = err instanceof Error ? err.message : String(err);
952
+ }
953
+ await beat(err instanceof ConflictError
954
+ ? `Waiting for ${id} — running; asking the guest.`
955
+ : `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
956
+ // The guest probe's own failure decides this turn's interval, for
957
+ // pollDelay's reason. The ordinary path below keeps POLL_MS.
958
+ await sleep(pollDelay(err), signal);
959
+ continue;
960
+ }
961
+ }
962
+ await sleep(POLL_MS, signal);
743
963
  }
744
- await sleep(POLL_MS, signal);
964
+ // Also a refusal: the deadline passed without the condition being met,
965
+ // which is the same shape of answer as a cancellation and not the same
966
+ // as success. The message still says to call again, because the state
967
+ // it was waiting on may yet arrive.
968
+ return refused(blocked
969
+ ? `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.`
970
+ : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
971
+ }
972
+ finally {
973
+ await beat.stop();
745
974
  }
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
975
  }));
754
976
  server.registerTool('get_desktop_url', {
755
977
  title: 'Get a link to watch the desktop',
@@ -761,7 +983,41 @@ export const registerComputers = (server, session, opts) => {
761
983
  .default(false)
762
984
  .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
985
  },
764
- annotations: { readOnlyHint: true },
986
+ // NO readOnlyHint, for a reason distinct from both the ones already in
987
+ // this codebase: the SPEND rule (OPL-4499 — cursor_position and read_file
988
+ // resume a suspended computer) and the CONSUMING-READ rule (exec_poll,
989
+ // poll_events and wait_for_event advance a cursor no later call can
990
+ // re-read, and wait_for_file_change also configures). This is a fourth,
991
+ // decided in OPL-4505 and written here so it is not re-litigated.
992
+ //
993
+ // `GET /computers/:id` modifies nothing and spends nothing, so under the
994
+ // rule as first written the hint was accurate. What the rule was missing
995
+ // is that the annotation does not only describe the route: clients treat
996
+ // it as licence to call without asking, which is the reading input.ts
997
+ // already states beside `cursor_position`. This call hands back a
998
+ // credential — with `control: true`, one that is root-equivalent on the
999
+ // machine — so under a host that auto-approves read-only tools, a model
1000
+ // could pass out control of a desktop with nobody asked. The description
1001
+ // says "these are credentials in a link", but a description is not a gate.
1002
+ //
1003
+ // So, added to the list: the hint is withheld from a tool that returns a
1004
+ // CREDENTIAL-EQUIVALENT. This is the only one today; the next tool that
1005
+ // hands back a token, a signed URL or a key belongs here with it rather
1006
+ // than in an argument about whether a read is a read.
1007
+ //
1008
+ // Credential-equivalent, and deliberately not "sensitive". A screenshot
1009
+ // of a desktop with a password on it is sensitive, and it is not
1010
+ // something this call revealed the keys to — a rule drawn that wide would
1011
+ // take the hint off every read there is, which is why `screenshot`,
1012
+ // `read_clipboard` and `list_windows` keep theirs.
1013
+ //
1014
+ // The other two flags are set rather than left to default, for the reason
1015
+ // `cursor_position` sets them: the spec defaults destructiveHint to TRUE
1016
+ // and idempotentHint to FALSE once readOnlyHint is gone, so leaving them
1017
+ // out would have a host asking whether it may perform destructive updates
1018
+ // in order to read a URL, and refusing to retry a call that is safe to
1019
+ // retry.
1020
+ annotations: { destructiveHint: false, idempotentHint: true },
765
1021
  }, ({ computer_id, control }, extra) => guarded(async () => {
766
1022
  const id = session.resolve(computer_id);
767
1023
  const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
@@ -874,7 +1130,7 @@ export const registerComputers = (server, session, opts) => {
874
1130
  return;
875
1131
  server.registerTool('create_computer', {
876
1132
  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.',
1133
+ description: 'Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account. Continue an image preparation refusal only as its result instructs, retaining all original create arguments, including template, and adding the returned preparation token as an argument. This token is not a create idempotency key. Stop after success; never automatically replay after a lost or ambiguous response.',
878
1134
  inputSchema: {
879
1135
  name: z.string().optional().describe('A label. The platform picks one if you do not.'),
880
1136
  size: z
@@ -885,6 +1141,11 @@ export const registerComputers = (server, session, opts) => {
885
1141
  .string()
886
1142
  .optional()
887
1143
  .describe('From list_templates, e.g. "base" for Linux/Xfce. Defaults to the platform default.'),
1144
+ template_transfer: z
1145
+ .string()
1146
+ .refine((value) => value.trim().length > 0, 'template_transfer must not be blank')
1147
+ .optional()
1148
+ .describe('Nonblank opaque token from an image preparation refusal; preserve it exactly. When the result permits continuation, keep all original create arguments including template, add this token, and wait for the supplied delay. Requires template and cannot be combined with size. Not a create idempotency key: stop after success and never automatically replay after a lost or ambiguous response.'),
888
1149
  cpu: z.number().int().min(1).optional(),
889
1150
  ram_mb: z.number().int().min(512).optional(),
890
1151
  disk_gb: z
@@ -901,9 +1162,48 @@ export const registerComputers = (server, session, opts) => {
901
1162
  },
902
1163
  annotations: { destructiveHint: false, openWorldHint: true },
903
1164
  }, (args, extra) => guarded(async () => {
904
- const c = unwrapComputer(await session.api
905
- .with(extra.signal)
906
- .json('POST', P.COMPUTERS, { body: P.createBody(args) }));
1165
+ let data;
1166
+ try {
1167
+ data = await session.api
1168
+ .with(extra.signal)
1169
+ .json('POST', P.COMPUTERS, { body: P.createBody(args) });
1170
+ }
1171
+ catch (error) {
1172
+ const body = error instanceof ConflictError
1173
+ ? error.body
1174
+ : undefined;
1175
+ if (body?.code === 'template_image_preparing' && error instanceof ConflictError) {
1176
+ const preparation = body.preparation;
1177
+ const state = preparation && typeof preparation === 'object'
1178
+ ? preparation.state
1179
+ : undefined;
1180
+ const tokenPresent = typeof body.template_transfer === 'string' &&
1181
+ body.template_transfer.trim().length > 0;
1182
+ const originalSupportsContinuation = typeof args.template === 'string' &&
1183
+ args.template.trim().length > 0 &&
1184
+ args.size === undefined;
1185
+ const canContinue = originalSupportsContinuation &&
1186
+ tokenPresent &&
1187
+ typeof state === 'string' &&
1188
+ ['preparing', 'copying', 'ready'].includes(state) &&
1189
+ error.retryAfterMs !== undefined;
1190
+ const advice = state === 'failed'
1191
+ ? 'Image preparation failed. Inspect preparation.error before deciding what to do next; do not automatically wait or retry.'
1192
+ : canContinue
1193
+ ? `Wait ${error.retryAfterMs} milliseconds, then repeat create_computer with all original identical arguments, including template, and this template_transfer token. Stop after success.`
1194
+ : !originalSupportsContinuation
1195
+ ? 'The original create must include a nonblank template and omit size to continue with this token. Inspect the original request and preparation details; do not automatically wait or retry.'
1196
+ : 'The response does not supply a known continuation state, usable token, and valid delay. Inspect the preparation details; do not automatically wait or retry.';
1197
+ return refused(`${typeof body.error === 'string' ? body.error : 'The template image is not available for this create.'} No computer has been created. ${advice} The token is not a create idempotency key. Never automatically replay after a lost or ambiguous response.`, {
1198
+ code: body.code,
1199
+ template_transfer: body.template_transfer,
1200
+ preparation,
1201
+ retry_after_ms: error.retryAfterMs,
1202
+ });
1203
+ }
1204
+ throw error;
1205
+ }
1206
+ const c = unwrapComputer(data);
907
1207
  // Selection and the sentence claiming it are the same decision. Bound
908
1208
  // conditionally and reported unconditionally, a create that came back
909
1209
  // without an id left this session pointing at whatever it held before
@@ -920,7 +1220,9 @@ export const registerComputers = (server, session, opts) => {
920
1220
  const note = c.start_error
921
1221
  ? `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
1222
  : `Created and selected ${describe(c)}.`;
923
- return said(note, withoutCredentials(c));
1223
+ return said(args.template_transfer === undefined
1224
+ ? note
1225
+ : `${note} Stop retrying this create; the template_transfer token is not a create idempotency key.`, withoutCredentials(c));
924
1226
  }));
925
1227
  server.registerTool('clone_computer', {
926
1228
  title: 'Clone a computer',
@@ -968,7 +1270,7 @@ export const registerComputers = (server, session, opts) => {
968
1270
  // Not fetched on the caller's behalf, which was the tempting shortcut
969
1271
  // and is the wrong one. A fingerprint read a millisecond before the
970
1272
  // 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:
1273
+ // agreed to — and the race the expectation exists for is exactly that:
972
1274
  // a capture that finishes between the decision and the click, then gets
973
1275
  // destroyed by a confirmation that predates it.
974
1276
  const fingerprint = expect?.trim() || undefined;
@@ -1000,8 +1302,32 @@ export const registerComputers = (server, session, opts) => {
1000
1302
  // Unbound BEFORE the rethrow-or-report decision, because it is true
1001
1303
  // either way: whatever this answers, the caller must not be left
1002
1304
  // selected on a computer the platform says is gone.
1003
- if (!(err instanceof NotFoundError))
1305
+ if (!(err instanceof NotFoundError)) {
1306
+ // The VM is destroyed before snapshots are purged, so a failed
1307
+ // DELETE does not establish whether it survived. Ask once, with
1308
+ // the same account/workspace scope, and preserve the primary error
1309
+ // even if this read fails. Neither error prose nor a lost or
1310
+ // unreachable record proves deletion. The short deadline includes
1311
+ // reading the body; reconciliation must not hold the error hostage
1312
+ // to the transport's much longer foreground-exec allowance.
1313
+ if (!extra.signal.aborted) {
1314
+ const signal = AbortSignal.any([extra.signal, AbortSignal.timeout(5_000)]);
1315
+ let absent = false;
1316
+ try {
1317
+ const c = unwrapComputer(await session.api.with(signal).json('GET', P.computer(computer_id)));
1318
+ absent =
1319
+ c.id === computer_id.trim() && c.state === 'deleted' && c.unreachable !== true;
1320
+ }
1321
+ catch (readError) {
1322
+ absent = readError instanceof NotFoundError;
1323
+ }
1324
+ // unbind also drops events and invalidates pending selections
1325
+ // of this id, while preserving a different selected computer.
1326
+ if (absent && !signal.aborted)
1327
+ session.unbind(computer_id);
1328
+ }
1004
1329
  throw err;
1330
+ }
1005
1331
  session.unbind(computer_id);
1006
1332
  // Reported as a success rather than an error, and deliberately not as
1007
1333
  // a plain "Deleted": a caller retrying cannot be told its snapshots