@intentic/sandbox-contract 1.238.1 → 1.240.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 (41) hide show
  1. package/dist/contracts/agent.contract.d.ts +49 -30
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agent.contract.js +1 -1
  4. package/dist/contracts/agent.contract.js.map +1 -1
  5. package/dist/contracts/agents.contract.d.ts +474 -0
  6. package/dist/contracts/agents.contract.d.ts.map +1 -1
  7. package/dist/contracts/runner.contract.d.ts +40 -30
  8. package/dist/contracts/runner.contract.d.ts.map +1 -1
  9. package/dist/contracts/sessions.contract.d.ts +474 -0
  10. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  11. package/dist/contracts/system.contract.d.ts +474 -0
  12. package/dist/contracts/system.contract.d.ts.map +1 -1
  13. package/dist/events.d.ts +2546 -123
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +122 -61
  16. package/dist/events.js.map +1 -1
  17. package/dist/index.d.ts +1607 -165
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/schemas/engines.d.ts +157 -0
  22. package/dist/schemas/engines.d.ts.map +1 -0
  23. package/dist/schemas/engines.js +70 -0
  24. package/dist/schemas/engines.js.map +1 -0
  25. package/dist/schemas/plan-limits.d.ts +19 -0
  26. package/dist/schemas/plan-limits.d.ts.map +1 -1
  27. package/dist/schemas/plan-limits.js +8 -1
  28. package/dist/schemas/plan-limits.js.map +1 -1
  29. package/dist/workspace-state.d.ts +5 -0
  30. package/dist/workspace-state.d.ts.map +1 -1
  31. package/dist/workspace-state.js +1 -0
  32. package/dist/workspace-state.js.map +1 -1
  33. package/package.json +4 -4
  34. package/src/contracts/agent.contract.ts +1 -1
  35. package/src/events.test.ts +14 -0
  36. package/src/events.ts +250 -104
  37. package/src/index.ts +1 -0
  38. package/src/schemas/engines.ts +129 -0
  39. package/src/schemas/plan-limits.ts +31 -12
  40. package/src/workspace-state.test.ts +5 -0
  41. package/src/workspace-state.ts +12 -0
package/src/events.ts CHANGED
@@ -286,6 +286,168 @@ export const ToolCallContentSchema = z.discriminatedUnion("type", [
286
286
  ]);
287
287
  export type ToolCallContent = z.infer<typeof ToolCallContentSchema>;
288
288
 
289
+ /* WHAT A PARKED CARD IS ABOUT: the document the turn wrote and is now asking a question against.
290
+ *
291
+ * A card asks for a decision; until this it carried no SUBJECT. The commonest shape of a real decision is "I
292
+ * analysed this and wrote it up, now choose", and the write-up went into a file whose card had already folded
293
+ * itself into `Write · +135 −0` twenty tool calls back. So the reader was asked to choose between options
294
+ * describing a document the chat had never shown them.
295
+ *
296
+ * Carried BY VALUE rather than as a path, for the same reason the diff on a tool call is: the bytes are already
297
+ * in hand when the card is raised, a path would make the card's meaning depend on a file that keeps changing
298
+ * under it, and a restored or published transcript has no workspace to go read. The path rides along anyway, so
299
+ * a document past the wire cap still has somewhere to send the reader.
300
+ *
301
+ * Nothing is asked of the MODEL for this. It calls `ask` exactly as before; the daemon knows what the turn
302
+ * wrote, because every write came past it as a frame (documents.ts decides which of them is a document). A
303
+ * harness that can see the answer must not spend prompt on asking the model to repeat it. */
304
+ export const CardDocumentSchema = z.object({
305
+ path: z.string().describe("Where it lives, as a workspace path."),
306
+ title: z.string().describe("What it is called: its opening heading, or its file name."),
307
+ markdown: z.string().describe("The document itself."),
308
+ truncated: z.boolean().optional().describe("It was clipped at the wire cap; the file on disk has more."),
309
+ plan: z.boolean().optional().describe("It is one of the CLI's plan files, written to be approved rather than merely read."),
310
+ });
311
+ export type CardDocument = z.infer<typeof CardDocumentSchema>;
312
+
313
+ /* ONE CARD'S OWN FIELDS, spelled once. Three readers carry the same card and must agree on what it is: the
314
+ * frame that raises it (AgentEventSchema below), the journal entry that keeps a parked one across a restart
315
+ * (ParkedCardSchema), and the record row that keeps it for good (RestoredMessageSchema's card fields). A shape
316
+ * declared inline in each was three shapes with one name. */
317
+ const REQUEST_ID = z.string().describe("What to send back when you answer.");
318
+ const planCard = {
319
+ requestId: REQUEST_ID,
320
+ text: z.string().describe("The plan itself."),
321
+ // Present when the adjacent plan prose POINTS at a document instead of being one: the model wrote the real
322
+ // plan to a file and summarised it there. Absent when the text already is the whole plan.
323
+ document: CardDocumentSchema.optional().describe("The write-up this plan refers to, when the plan itself is a pointer to one."),
324
+ };
325
+ const questionCard = {
326
+ requestId: REQUEST_ID,
327
+ questions: z.array(AskQuestionSchema).describe("What it wants to know."),
328
+ document: CardDocumentSchema.optional().describe("The document this turn wrote and is asking about, so the choice can be read beside it."),
329
+ };
330
+ const permissionCard = { requestId: REQUEST_ID };
331
+ // The agent's browser needs a person: it parked mid-sign-in on something it cannot clear itself (a captcha,
332
+ // a password it does not hold, a phone check). `session` names the browser session on /browsers, the card's
333
+ // one action is going THERE, where the live stage and Take control already are; the Browsers banner and this
334
+ // card resolve the same requestId. `account` is the capability the sign-in is for, so the card can say whose
335
+ // login is stuck even after the browser has navigated somewhere unrecognizable.
336
+ const browserHelpCard = {
337
+ requestId: z.string(),
338
+ session: z.string(),
339
+ account: z.string(),
340
+ message: z.string(),
341
+ };
342
+ // The agent's TERMINAL needs a person: a command it started is sitting at a prompt it cannot answer (a
343
+ // one-time password, a security-key touch, a confirm). `session` names the tmux session on the terminal
344
+ // panel, the card's one action is going THERE, where the live pane and its prompt already are, which is
345
+ // the same division of labour the browser card has with /browsers.
346
+ const terminalHelpCard = {
347
+ requestId: z.string(),
348
+ session: z.string(),
349
+ message: z.string(),
350
+ };
351
+ const serviceOfferCard = { requestId: z.string(), offer: ServiceOfferSchema };
352
+ const capabilityOfferCard = { requestId: z.string(), offer: CapabilityOfferSchema };
353
+ const paymentOfferCard = { requestId: z.string(), offer: PaymentOfferSchema };
354
+
355
+ /* HOW AN OFFER'S ACCEPTED HALF ENDED, the follow-up that lands on the card after the click. Each is the body of
356
+ * the frame that reports it (`service_receipt`, `capability_outcome`, `payment_receipt`) and the field the
357
+ * record keeps it in, one shape for both, so a receipt reopened tomorrow says exactly what the live card said. */
358
+ export const ServiceReceiptSchema = z.object({
359
+ outcome: z.enum(["ok", "refunded", "refused"]),
360
+ credits: z.number(),
361
+ remaining: z.number().optional(),
362
+ });
363
+ export type ServiceReceipt = z.infer<typeof ServiceReceiptSchema>;
364
+ export const CapabilityOutcomeSchema = z.object({
365
+ outcome: z.enum(["connected", "unfinished"]),
366
+ id: z.string().optional(),
367
+ });
368
+ export type CapabilityOutcome = z.infer<typeof CapabilityOutcomeSchema>;
369
+ export const PaymentReceiptSchema = z.object({
370
+ outcome: z.enum(["paid", "failed"]),
371
+ amountUsd: z.string(),
372
+ transaction: z.string().optional(),
373
+ network: z.string().optional(),
374
+ });
375
+ export type PaymentReceipt = z.infer<typeof PaymentReceiptSchema>;
376
+
377
+ /* THE THREE RESTORABLE CARDS, named so the turn journal can hold them verbatim: a parked turn's raised cards
378
+ * are written down beside its prompt (sandbox turn-journal.ts), and a daemon death under the park restores the
379
+ * very same frames instead of ending the turn `interrupted`, the card the user was about to answer survives
380
+ * the restart that killed the process holding it. The two handover cards are deliberately not among them:
381
+ * `browser_help`'s Chromium and `terminal_help`'s waiting command both die with the container, so those parks
382
+ * cannot be restored, only reported. */
383
+ const PlanCardSchema = z.object({
384
+ kind: z.literal("plan").describe("The agent has written a plan and is waiting for a yes."),
385
+ ...planCard,
386
+ });
387
+ const QuestionCardSchema = z.object({
388
+ kind: z.literal("question").describe("The agent has asked you something and is waiting."),
389
+ ...questionCard,
390
+ });
391
+ const PermissionCardSchema = PermissionAskSchema.extend({
392
+ kind: z.literal("permission").describe("The agent wants to use a tool it needs permission for."),
393
+ ...permissionCard,
394
+ });
395
+ export const ParkedCardSchema = z.discriminatedUnion("kind", [PlanCardSchema, QuestionCardSchema, PermissionCardSchema]);
396
+ export type ParkedCard = z.infer<typeof ParkedCardSchema>;
397
+
398
+ // ---- restored cards ----
399
+ /* THE CARDS A TURN PARKED ON, as the record keeps them: the card exactly as it was raised, and the reply that
400
+ * released it exactly as the client sent it (the `resolved` frame's own payload), plus whatever landed on the
401
+ * card afterwards (a permission's late explanation, an offer's stream and receipt).
402
+ *
403
+ * They exist because the record used to keep NONE of this. A question the user answered was on screen for as
404
+ * long as the turn's frame log lived (minutes) and in the browser's local mirror for as long as that survived
405
+ * (until the next web build), and then the record repainted the conversation without it: the card, the
406
+ * questions, and the user's own picks, gone from the only durable copy. The rule the record follows is that a
407
+ * reopened chat redraws what was on screen, and a decision the user made is the part of a conversation they
408
+ * come back to re-read.
409
+ *
410
+ * The REPLY rides verbatim rather than as a derived status, for the same reason the `resolved` frame carries
411
+ * it that way: the client already turns a reply into the card's frozen state for the live stream, and a
412
+ * restored card goes through that one function too, so the two can never disagree about what "answered"
413
+ * looks like. Absent, nobody answered (the turn was stopped, or died under the card), which is not a decision
414
+ * and must not replay as one. */
415
+ const settled = {
416
+ reply: AgentReplySchema.optional().describe(
417
+ "How it was answered, exactly as the client sent it. Absent when nobody did: the turn was stopped or died under the card, which is not a decision and does not read back as one.",
418
+ ),
419
+ };
420
+ export const RestoredPlanSchema = z.object({ ...planCard, ...settled });
421
+ export type RestoredPlan = z.infer<typeof RestoredPlanSchema>;
422
+ export const RestoredQuestionSchema = z.object({ ...questionCard, ...settled });
423
+ export type RestoredQuestion = z.infer<typeof RestoredQuestionSchema>;
424
+ // `explain`, the quick model's late sentence (the `permission_note` frame), lands here through PermissionAskSchema.
425
+ export const RestoredPermissionSchema = PermissionAskSchema.extend({ ...permissionCard, ...settled });
426
+ export type RestoredPermission = z.infer<typeof RestoredPermissionSchema>;
427
+ export const RestoredBrowserHelpSchema = z.object({ ...browserHelpCard, ...settled });
428
+ export type RestoredBrowserHelp = z.infer<typeof RestoredBrowserHelpSchema>;
429
+ export const RestoredTerminalHelpSchema = z.object({ ...terminalHelpCard, ...settled });
430
+ export type RestoredTerminalHelp = z.infer<typeof RestoredTerminalHelpSchema>;
431
+ export const RestoredServiceOfferSchema = z.object({
432
+ ...serviceOfferCard,
433
+ ...settled,
434
+ events: z.array(ServiceStreamEventSchema).optional().describe("The approved run's stream, in order (the service_event frames)."),
435
+ receipt: ServiceReceiptSchema.optional().describe("How the approved run ended (the service_receipt frame)."),
436
+ });
437
+ export type RestoredServiceOffer = z.infer<typeof RestoredServiceOfferSchema>;
438
+ export const RestoredCapabilityOfferSchema = z.object({
439
+ ...capabilityOfferCard,
440
+ ...settled,
441
+ outcome: CapabilityOutcomeSchema.optional().describe("How an accepted ask's setup ended (the capability_outcome frame)."),
442
+ });
443
+ export type RestoredCapabilityOffer = z.infer<typeof RestoredCapabilityOfferSchema>;
444
+ export const RestoredPaymentOfferSchema = z.object({
445
+ ...paymentOfferCard,
446
+ ...settled,
447
+ receipt: PaymentReceiptSchema.optional().describe("How the approved payment ended (the payment_receipt frame)."),
448
+ });
449
+ export type RestoredPaymentOffer = z.infer<typeof RestoredPaymentOfferSchema>;
450
+
289
451
  // ---- restored transcripts ----
290
452
  // What /sessions/{id} replays into a reopened tab, and what the daemon's own conversation record stores. It has
291
453
  // to REDRAW the transcript the user was looking at rather than merely paraphrase it, so it keeps the assistant's
@@ -427,9 +589,42 @@ export const RestoredMessageSchema = z.object({
427
589
  .enum(["tierHold"])
428
590
  .optional()
429
591
  .describe("A one-press follow-up this recorded notice offers, by name. The chat decides what it does and whether it still applies."),
592
+ /* THE CARD THIS BUBBLE PARKED ON (assistant rows only), at most one: a card closes the bubble it lands in,
593
+ * live (turnReducer nulls the turn's bubble) and in the fold alike (sessions/turn-transcript.ts), so the
594
+ * next thing the agent says opens a fresh row beneath it. One field per kind rather than one union field,
595
+ * because that is the shape the live ChatMessage has and a restored row is meant to be indistinguishable
596
+ * from the one it replaces. See the restored-cards section above for why these exist at all. */
597
+ plan: RestoredPlanSchema.optional().describe("The plan this row asked approval for, and the answer."),
598
+ question: RestoredQuestionSchema.optional().describe("The questions this row asked, and the picks that answered them."),
599
+ permission: RestoredPermissionSchema.optional().describe("The tool this row asked permission for, and the decision."),
600
+ browserHelp: RestoredBrowserHelpSchema.optional().describe("The browser hand-over this row asked for, and how it ended."),
601
+ terminalHelp: RestoredTerminalHelpSchema.optional().describe("The terminal hand-over this row asked for, and how it ended."),
602
+ serviceOffer: RestoredServiceOfferSchema.optional().describe("The priced service run this row offered, the decision, and the receipt."),
603
+ capabilityOffer: RestoredCapabilityOfferSchema.optional().describe("The capability setup this row asked for, the decision, and the outcome."),
604
+ paymentOffer: RestoredPaymentOfferSchema.optional().describe("The payment this row asked for, the decision, and the receipt."),
430
605
  });
431
606
  export type RestoredMessage = z.infer<typeof RestoredMessageSchema>;
432
607
 
608
+ /* THE CARD FIELDS A ROW CAN CARRY, as one list, for every reader that has to ask "does this row hold a card":
609
+ * the fold that counts a card-only bubble as a row (sessions/turn-transcript.ts), the client's own row count
610
+ * (recordedRows), which must agree with it to the row or a branch is cut in the wrong place, and the client's
611
+ * restore, which turns each into its live card. The live ChatMessage names its cards exactly this way, so the
612
+ * list is the same list on both sides rather than two that have to be kept in step. */
613
+ export const RESTORED_CARD_FIELDS = [
614
+ "plan",
615
+ "question",
616
+ "permission",
617
+ "browserHelp",
618
+ "terminalHelp",
619
+ "serviceOffer",
620
+ "capabilityOffer",
621
+ "paymentOffer",
622
+ ] as const;
623
+ export type RestoredCardField = (typeof RESTORED_CARD_FIELDS)[number];
624
+ export type RestoredCards = Pick<RestoredMessage, RestoredCardField>;
625
+ // Whether a row holds a card at all, the question the row counts on both sides ask.
626
+ export const holdsCard = (message: RestoredCards): boolean => RESTORED_CARD_FIELDS.some((field) => message[field] !== undefined);
627
+
433
628
  export const SessionTranscriptSchema = z.object({
434
629
  messages: z
435
630
  .array(RestoredMessageSchema)
@@ -477,57 +672,6 @@ export const SharePayloadSchema = z.object({
477
672
  });
478
673
  export type SharePayload = z.infer<typeof SharePayloadSchema>;
479
674
 
480
- /* WHAT A PARKED CARD IS ABOUT: the document the turn wrote and is now asking a question against.
481
- *
482
- * A card asks for a decision; until this it carried no SUBJECT. The commonest shape of a real decision is "I
483
- * analysed this and wrote it up, now choose", and the write-up went into a file whose card had already folded
484
- * itself into `Write · +135 −0` twenty tool calls back. So the reader was asked to choose between options
485
- * describing a document the chat had never shown them.
486
- *
487
- * Carried BY VALUE rather than as a path, for the same reason the diff on a tool call is: the bytes are already
488
- * in hand when the card is raised, a path would make the card's meaning depend on a file that keeps changing
489
- * under it, and a restored or published transcript has no workspace to go read. The path rides along anyway, so
490
- * a document past the wire cap still has somewhere to send the reader.
491
- *
492
- * Nothing is asked of the MODEL for this. It calls `ask` exactly as before; the daemon knows what the turn
493
- * wrote, because every write came past it as a frame (documents.ts decides which of them is a document). A
494
- * harness that can see the answer must not spend prompt on asking the model to repeat it. */
495
- export const CardDocumentSchema = z.object({
496
- path: z.string().describe("Where it lives, as a workspace path."),
497
- title: z.string().describe("What it is called: its opening heading, or its file name."),
498
- markdown: z.string().describe("The document itself."),
499
- truncated: z.boolean().optional().describe("It was clipped at the wire cap; the file on disk has more."),
500
- plan: z.boolean().optional().describe("It is one of the CLI's plan files, written to be approved rather than merely read."),
501
- });
502
- export type CardDocument = z.infer<typeof CardDocumentSchema>;
503
-
504
- /* THE THREE RESTORABLE CARDS, named so the turn journal can hold them verbatim: a parked turn's raised cards
505
- * are written down beside its prompt (sandbox turn-journal.ts), and a daemon death under the park restores the
506
- * very same frames instead of ending the turn `interrupted`, the card the user was about to answer survives
507
- * the restart that killed the process holding it. The two handover cards are deliberately not among them:
508
- * `browser_help`'s Chromium and `terminal_help`'s waiting command both die with the container, so those parks
509
- * cannot be restored, only reported. */
510
- const PlanCardSchema = z.object({
511
- kind: z.literal("plan").describe("The agent has written a plan and is waiting for a yes."),
512
- requestId: z.string().describe("What to send back when you answer."),
513
- text: z.string().describe("The plan itself."),
514
- // Present when the adjacent plan prose POINTS at a document instead of being one: the model wrote the real
515
- // plan to a file and summarised it there. Absent when the text already is the whole plan.
516
- document: CardDocumentSchema.optional().describe("The write-up this plan refers to, when the plan itself is a pointer to one."),
517
- });
518
- const QuestionCardSchema = z.object({
519
- kind: z.literal("question").describe("The agent has asked you something and is waiting."),
520
- requestId: z.string().describe("What to send back when you answer."),
521
- questions: z.array(AskQuestionSchema).describe("What it wants to know."),
522
- document: CardDocumentSchema.optional().describe("The document this turn wrote and is asking about, so the choice can be read beside it."),
523
- });
524
- const PermissionCardSchema = PermissionAskSchema.extend({
525
- kind: z.literal("permission").describe("The agent wants to use a tool it needs permission for."),
526
- requestId: z.string().describe("What to send back when you answer."),
527
- });
528
- export const ParkedCardSchema = z.discriminatedUnion("kind", [PlanCardSchema, QuestionCardSchema, PermissionCardSchema]);
529
- export type ParkedCard = z.infer<typeof ParkedCardSchema>;
530
-
531
675
  // One frame from an agent turn, relayed to the UI. `kind`-discriminated. The daemon normalizes the SDK's
532
676
  // ~40 SDKMessage types down to this union: high-value block types get a dedicated frame
533
677
  // (delta/thinking/tool_call/tool_call_update/todos/usage/rate_limit_info/account_usage/context_usage/init/compact); any SDK message
@@ -843,35 +987,18 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
843
987
  PlanCardSchema,
844
988
  QuestionCardSchema,
845
989
  PermissionCardSchema,
846
- // The agent's browser needs a person: it parked mid-sign-in on something it cannot clear itself (a captcha,
847
- // a password it does not hold, a phone check). `session` names the browser session on /browsers, the card's
848
- // one action is going THERE, where the live stage and Take control already are; the Browsers banner and this
849
- // card resolve the same requestId. `account` is the capability the sign-in is for, so the card can say whose
850
- // login is stuck even after the browser has navigated somewhere unrecognizable.
851
- z.object({
852
- kind: z.literal("browser_help"),
853
- requestId: z.string(),
854
- session: z.string(),
855
- account: z.string(),
856
- message: z.string(),
857
- }),
858
- // The agent's TERMINAL needs a person: a command it started is sitting at a prompt it cannot answer (a
859
- // one-time password, a security-key touch, a confirm). `session` names the tmux session on the terminal
860
- // panel, the card's one action is going THERE, where the live pane and its prompt already are, which is
861
- // the same division of labour the browser card has with /browsers. Not journalled for restore, and for the
990
+ // The agent's browser needs a person (see browserHelpCard for what the card carries). Not journalled for
991
+ // restore: the Chromium holding the page dies with the container.
992
+ z.object({ kind: z.literal("browser_help"), ...browserHelpCard }),
993
+ // The agent's TERMINAL needs a person (see terminalHelpCard). Not journalled for restore, and for the
862
994
  // browser card's reason one door along: the pane holding the prompt belongs to a process the restart kills.
863
- z.object({
864
- kind: z.literal("terminal_help"),
865
- requestId: z.string(),
866
- session: z.string(),
867
- message: z.string(),
868
- }),
995
+ z.object({ kind: z.literal("terminal_help"), ...terminalHelpCard }),
869
996
  /* A premium service run awaiting the owner's click. Raised OUTSIDE the turn generator, the daemon's
870
997
  * services route parks the agent's own `services run` call and pushes this frame into the live run
871
998
  * (platform/service-offer.ts), so unlike the four cards above it is not journalled for restore: its
872
999
  * waiter is the CLI's held connection, which dies with the daemon, and a restored card would offer
873
1000
  * buttons nothing is waiting behind. Settles through the same `POST /agent/reply` as every other card. */
874
- z.object({ kind: z.literal("service_offer"), requestId: z.string(), offer: ServiceOfferSchema }),
1001
+ z.object({ kind: z.literal("service_offer"), ...serviceOfferCard }),
875
1002
  /* One event off an approved run's stream, pushed as the provider emits it so the settled card shows the
876
1003
  * run living rather than a spinner of unknowable length. Today that is `status` lines; `result` stays off
877
1004
  * the transcript on purpose (it is the agent's answer to act on, not the card's to duplicate), the frame
@@ -881,49 +1008,31 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
881
1008
  * rather than a promise: `ok` served and charged, `refunded` failed to answer and charged nothing,
882
1009
  * `refused` the platform said no after the click (a raced-out allowance). `remaining` is the meter after,
883
1010
  * when the platform stated one. Skip needs no receipt, nothing happened, and `resolved` already says so. */
884
- z.object({
885
- kind: z.literal("service_receipt"),
886
- requestId: z.string(),
887
- outcome: z.enum(["ok", "refunded", "refused"]),
888
- credits: z.number(),
889
- remaining: z.number().optional(),
890
- }),
1011
+ ServiceReceiptSchema.extend({ kind: z.literal("service_receipt"), requestId: z.string() }),
891
1012
  /* A missing capability asking for the owner's setup, the agent hit something this sandbox is not
892
1013
  * connected to and raised the card instead of describing manual steps. Raised OUTSIDE the turn generator
893
1014
  * exactly like the service offer above (the daemon's ask route parks the agent's `capabilities request`
894
1015
  * call and pushes this frame into the live run; capabilities/capability-offer.ts), so it is not
895
1016
  * journalled for restore either: its waiter is the CLI's held connection, which dies with the daemon.
896
1017
  * Settles through the same `POST /agent/reply` as every other card. */
897
- z.object({ kind: z.literal("capability_offer"), requestId: z.string(), offer: CapabilityOfferSchema }),
1018
+ z.object({ kind: z.literal("capability_offer"), ...capabilityOfferCard }),
898
1019
  /* How an accepted ask ended, pushed once the daemon stops watching for the connection: `connected`, the
899
1020
  * capability came live while the agent waited (`id` is the connected instance, the agent's handle for it)
900
1021
  *, or `unfinished`, the setup did not complete while anyone was waiting (the deadline passed, or the
901
1022
  * asking command died). A skip needs no outcome frame, nothing was set up, and `resolved` already says
902
1023
  * so. It is what settles the card's "waiting for you to finish setup" state on every surface. */
903
- z.object({
904
- kind: z.literal("capability_outcome"),
905
- requestId: z.string(),
906
- outcome: z.enum(["connected", "unfinished"]),
907
- id: z.string().optional(),
908
- }),
1024
+ CapabilityOutcomeSchema.extend({ kind: z.literal("capability_outcome"), requestId: z.string() }),
909
1025
  /* A USDC payment awaiting the owner's click. Raised OUTSIDE the turn generator exactly like the service
910
1026
  * offer above (the daemon's wallet route parks the agent's `wallet fetch` call and pushes this frame into
911
1027
  * the live run; wallet/payment-offer.ts), so it is not journalled for restore either: its waiter is the
912
1028
  * CLI's held connection, which dies with the daemon. Settles through the same `POST /agent/reply`. */
913
- z.object({ kind: z.literal("payment_offer"), requestId: z.string(), offer: PaymentOfferSchema }),
1029
+ z.object({ kind: z.literal("payment_offer"), ...paymentOfferCard }),
914
1030
  /* How an approved (or auto-approved) payment ended, pushed after the endpoint answered so the card can
915
1031
  * settle as a receipt rather than a promise: `paid`, the endpoint confirmed settlement (`transaction` is
916
1032
  * the onchain hash when it stated one); `failed`, the payment was refused or settlement failed, in which
917
1033
  * case the signed authorization expires unused and NOTHING left the wallet. A skip needs no receipt,
918
1034
  * nothing moved, and `resolved` already says so. */
919
- z.object({
920
- kind: z.literal("payment_receipt"),
921
- requestId: z.string(),
922
- outcome: z.enum(["paid", "failed"]),
923
- amountUsd: z.string(),
924
- transaction: z.string().optional(),
925
- network: z.string().optional(),
926
- }),
1035
+ PaymentReceiptSchema.extend({ kind: z.literal("payment_receipt"), requestId: z.string() }),
927
1036
  // The card above named by `requestId` is released, the user answered (or dismissed it, or the turn was
928
1037
  // stopped out from under it), so the turn is executing again. Emitted by whoever parked, the moment its
929
1038
  // waiter settles, because the park's END is otherwise invisible on this stream: nothing else here says
@@ -1048,8 +1157,31 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
1048
1157
  * is a real classification all the same: it says the failure came from the LOOP rather than
1049
1158
  * from the provider, the credential or the request, which rules out every recovery next door. */
1050
1159
  "harness-incomplete",
1160
+ /* THE ENGINE IS TOO OLD FOR THE MODEL, and the provider says so in the same breath as the
1161
+ * version that would work ("Claude Code 2.1.233 does not support this model; version 2.1.251
1162
+ * or newer is required"). Its own code because the fix is unlike every neighbour's: nothing is
1163
+ * disconnected, nothing is spent, no retry of any length helps, and the thing that has to
1164
+ * change is not the request but the PROGRAM running it (schemas/engines.ts).
1165
+ *
1166
+ * It used to be unfixable from inside a sandbox at all — the engine came with the image, so a
1167
+ * whole fleet failed every turn on this model until a new image reached it. Now the daemon can
1168
+ * install the version the provider named, which is why this frame carries the numbers rather
1169
+ * than only the sentence: `engine` is what the card's Update button acts on. The install is
1170
+ * still a person's decision, because the version that satisfies a floor is by definition one
1171
+ * nobody has blessed yet. */
1172
+ "engine-version-floor",
1051
1173
  ])
1052
1174
  .optional(),
1175
+ /* engine-version-floor only: which engine is too old, what it is running, and the floor the provider
1176
+ * demanded. On the wire because the recovery is a specific, offerable action — install at or above
1177
+ * `floor` — and a client that had only the sentence would have to parse prose to offer it. */
1178
+ engine: z
1179
+ .object({
1180
+ id: z.string().describe("Which engine (e.g. claude)."),
1181
+ running: z.string().optional().describe("The version that was refused, when the provider named it."),
1182
+ floor: z.string().describe("The lowest version the provider will accept."),
1183
+ })
1184
+ .optional(),
1053
1185
  // rate_limit only: when the exhausted window reopens (epoch seconds, from the stream's own
1054
1186
  // rate_limit_event or the account's persisted usage windows). Absent when the reset instant is unknown
1055
1187
  // (nothing to schedule against).
@@ -1147,11 +1279,18 @@ export const RESUME_NOTES = {
1147
1279
  auth: `The Claude credential that interrupted this conversation has been renewed, and this turn resumed automatically. ${REPEATED}`,
1148
1280
  outage: `The model provider was briefly unavailable and interrupted this conversation; this turn resumed automatically. ${REPEATED}`,
1149
1281
  restart: `The sandbox restarted while this turn was running, which stopped it, and this turn resumed automatically once it came back. ${REPEATED}`,
1150
- /* A SPENT ALLOWANCE STRANDS A TURN IN TWO SHAPES, and they must not share a note.
1282
+ /* A SPENT ALLOWANCE STRANDS A TURN IN THREE SHAPES, and they must not share a note.
1151
1283
  *
1152
1284
  * `limit` is the mid-turn one and reads like its three neighbours above: the session holds real work, and
1153
1285
  * carrying on from it is exactly right.
1154
1286
  *
1287
+ * `switched` is that same mid-turn stranding picked back up on a DIFFERENT account (or provider, or
1288
+ * harness), which is what the composer's account switcher does between the refusal and the press. A session
1289
+ * belongs to the credential that minted it, so this one cannot resume: it opens a fresh session seeded from
1290
+ * the daemon's record. REPEATED's "already completed in this session" is therefore false where it counts —
1291
+ * the work is in the carried-across conversation, not in this session's own history — and a model told to
1292
+ * look for it there finds nothing and starts over silently.
1293
+ *
1155
1294
  * `refused` is the turn the provider turned away at the door, before the model read one word of it, and it
1156
1295
  * is the COMMONER of the two, because an allowance that is already spent refuses the first request it is
1157
1296
  * asked. REPEATED is actively wrong for it: "part of it was already completed in this session, continue from
@@ -1162,6 +1301,8 @@ export const RESUME_NOTES = {
1162
1301
  * allowance is the user's own budget and stays their call to spend (turn-resume.ts), so what re-ran this
1163
1302
  * turn was a person pressing Continue. */
1164
1303
  limit: `The model provider's usage allowance ran out while this turn was running, which stopped it, and it has been sent again. ${REPEATED}`,
1304
+ switched:
1305
+ "The model provider's usage allowance ran out while this turn was running, which stopped it, and it has been sent again on a different account, which starts a fresh session. The conversation so far has been carried across above, including the part of the request that was already completed: continue from that point instead of starting over.",
1165
1306
  refused:
1166
1307
  "The model provider refused the previous attempt at this request outright, because its usage allowance was spent: no part of the request below was read or acted on, and nothing has been done towards it. It has been sent again, and starts from the beginning.",
1167
1308
  // A turn that was PARKED on the user when the daemon died: nothing re-runs at boot, the card is restored
@@ -1209,13 +1350,18 @@ const RESUME_DISCLOSURES: Record<ResumeReason, ResumeDisclosure> = {
1209
1350
  auth: { kind: "notice", text: "Claude sign-in renewed, this turn picked up where it left off." },
1210
1351
  outage: { kind: "notice", text: "The model provider came back, this turn picked up where it left off." },
1211
1352
  restart: { kind: "notice", text: "The sandbox came back, this turn picked up where it left off." },
1212
- /* THE ONE PAIR NOBODY AUTOMATED, said in the passive voice the other three earn honestly and these two do
1213
- * not: a person pressed Continue. Which is the whole reason these rows exist at all. A press used to append
1214
- * the word "Continue" as a message of its own, so a chat that bounced off a spent allowance four times read
1215
- * back as the user saying "Continue" four times to an agent that had answered none of them, and the provider
1216
- * session the model actually reads accumulated all four (plus a synthetic "No response requested." per
1217
- * press). One row for one press was never the problem; a row that claims the user said something new is. */
1353
+ /* THE THREE NOBODY AUTOMATED, said in the passive voice the other three earn honestly and these do not: a
1354
+ * person pressed Continue. Which is the whole reason these rows exist at all. A press used to append the word
1355
+ * "Continue" as a message of its own, so a chat that bounced off a spent allowance four times read back as
1356
+ * the user saying "Continue" four times to an agent that had answered none of them, and the provider session
1357
+ * the model actually reads accumulated all four (plus a synthetic "No response requested." per press). One
1358
+ * row for one press was never the problem; a row that claims the user said something new is.
1359
+ *
1360
+ * `switched` names the account because that is the fact the reader needs: they pressed the same button they
1361
+ * pressed a minute ago, and the difference between the press that bounced and the press that worked is who
1362
+ * served it. The line is also the only place a retired session is accounted for. */
1218
1363
  limit: { kind: "notice", text: "Sent again after the allowance ran out mid-turn, picking up where it left off." },
1364
+ switched: { kind: "notice", text: "Sent again on the switched account after the allowance ran out mid-turn, in a fresh session." },
1219
1365
  refused: { kind: "notice", text: "Sent again after the allowance refused it: nothing had run." },
1220
1366
  answered: { kind: "note", note: { title: "Picked back up after a sandbox restart", text: RESUME_NOTES.answered } },
1221
1367
  };
package/src/index.ts CHANGED
@@ -144,6 +144,7 @@ export * from "./schemas/claude-gate.js";
144
144
  export * from "./schemas/codebase-health.js";
145
145
  export * from "./schemas/computers.js";
146
146
  export * from "./schemas/drafts.js";
147
+ export * from "./schemas/engines.js";
147
148
  export * from "./schemas/environment.js";
148
149
  export * from "./schemas/exit.js";
149
150
  export * from "./schemas/extension-updates.js";
@@ -0,0 +1,129 @@
1
+ // engines: the upstream agent programs this sandbox spawns, and which version of each it runs
2
+ import { z } from "zod";
3
+
4
+ /* AN ENGINE IS THE PROGRAM A RUNTIME RIDES ON, which is a different noun from `runtime` and has to stay one.
5
+ *
6
+ * `runtime` (agent-catalog.ts) is the LOOP: claude-code, codex, opencode, acp, pi, cursor — what shape a turn
7
+ * has. An engine is the installed PROGRAM that loop spawns or imports: the `claude` binary and its SDK, the
8
+ * `codex` wrapper, `@cursor/sdk`, the `opencode` binary, the `cli-proxy-api` translator. One engine can back
9
+ * several runtimes (the translator serves Gemini, Kimi and ChatGPT; Kimi runs under the Claude Code loop), so
10
+ * the two lists are neither the same set nor the same question.
11
+ *
12
+ * The distinction earns its keep because engines have a lifecycle runtimes do not: they are published upstream,
13
+ * on somebody else's schedule, and until this existed the only way to move one was to ship a sandbox image.
14
+ * That made an ordinary upstream event — Anthropic raising the version floor a model requires — into a support
15
+ * problem: every sandbox in the fleet failed every Claude turn until a new image reached it.
16
+ *
17
+ * So an engine version now comes from a STORE on the daemon volume, and the image's copy is the floor beneath
18
+ * it. The blessed list says which version this project has actually run its suite against; an owner who wants
19
+ * upstream's newest without waiting for that says so per engine. */
20
+
21
+ export const ENGINE_IDS = ["claude", "codex", "cursor", "opencode", "translator"] as const;
22
+ export type EngineId = (typeof ENGINE_IDS)[number];
23
+ export const EngineIdSchema = z.enum(ENGINE_IDS);
24
+
25
+ /* THE OWNER'S STANDING ANSWER for one engine, and the whole of the version policy.
26
+ *
27
+ * blessed , the default: whatever the blessed list names, which is a version this project's CI has run
28
+ * against. Data, not an image, so blessing a version reaches a running sandbox in seconds.
29
+ * latest , upstream's newest published version, taken without waiting for anyone to bless it. The opt-in
30
+ * for an owner who would rather have the fix than the assurance.
31
+ * pinned , exactly this version, until they say otherwise. What a revert leaves behind, and what somebody
32
+ * debugging a regression wants.
33
+ * image , do not use the store at all: run what the image bakes. The way back to a stock sandbox. */
34
+ export const EngineChannelSchema = z.object({
35
+ kind: z.enum(["blessed", "latest", "pinned", "image"]).describe("Where this engine's version comes from."),
36
+ version: z.string().optional().describe("Which version, when it is pinned to one."),
37
+ });
38
+ export type EngineChannel = z.infer<typeof EngineChannelSchema>;
39
+
40
+ // A version the store installed and then refused, with the reason it was refused. Kept per engine so a bad
41
+ // publish is not retried on a timer forever, and so the card can say why the sandbox is back on the image's
42
+ // copy rather than leaving that as an unexplained downgrade.
43
+ export const EngineQuarantineSchema = z.object({
44
+ version: z.string().describe("Which version was refused."),
45
+ reason: z.string().describe("What was wrong with it: it would not launch, or it did not export what the daemon calls."),
46
+ at: z.string().describe("When it was refused."),
47
+ });
48
+ export type EngineQuarantine = z.infer<typeof EngineQuarantineSchema>;
49
+
50
+ // One engine as the Environment card draws it: what is running, what is on offer, and what the ways out are.
51
+ export const EngineRowSchema = z.object({
52
+ id: EngineIdSchema.describe("Which engine."),
53
+ label: z.string().describe("What it is called on screen."),
54
+ running: z
55
+ .object({
56
+ // Absent means this sandbox has no copy of the engine at all: a core image bakes no provider packs,
57
+ // and until the store installs one, a turn on that provider cannot start. Different from "running
58
+ // the image's copy", and the card says so rather than drawing a blank version.
59
+ version: z.string().optional().describe("The version a turn would use right now. Absent means there is no copy of this engine here yet."),
60
+ source: z
61
+ .enum(["image", "store"])
62
+ .describe("Whether that version is the one baked into the sandbox image or one the store installed over it."),
63
+ })
64
+ .describe("What a turn started now would actually run."),
65
+ baked: z
66
+ .string()
67
+ .optional()
68
+ .describe("The version the image bakes, which is the floor everything else falls back to. Absent on an image that carries no copy of it."),
69
+ channel: EngineChannelSchema.describe("The owner's standing answer for this engine."),
70
+ // What the channel would move to, absent when the running version is already it. Carries whether the
71
+ // blessed list names it, because on `latest` the answer is routinely no and the row has to say so.
72
+ offered: z
73
+ .object({
74
+ version: z.string().describe("The version this engine would move to."),
75
+ blessed: z.boolean().describe("Whether the blessed list names this version, which on the latest channel is routinely no."),
76
+ })
77
+ .optional()
78
+ .describe("A newer version waiting, absent when the running one is already what the channel asks for."),
79
+ blessed: z.string().optional().describe("What the blessed list names for this engine, when the list has been read."),
80
+ // The store keeps one version back so a revert is a pointer move rather than a download. Absent on an
81
+ // engine that has only ever run the image's copy.
82
+ previous: z.string().optional().describe("The version kept one step back, which is what going back means."),
83
+ quarantined: z.array(EngineQuarantineSchema).describe("Versions the store installed and then refused, with the reason."),
84
+ diskBytes: z.number().int().nonnegative().describe("What this engine's kept versions cost on the daemon's volume."),
85
+ });
86
+ export type EngineRow = z.infer<typeof EngineRowSchema>;
87
+
88
+ export const EnginesViewSchema = z.object({
89
+ engines: z.array(EngineRowSchema).describe("Every engine this sandbox can run, whether or not the store holds anything for it."),
90
+ checkedAt: z.string().optional().describe("When upstream was last asked what it publishes. Absent until the first check has run."),
91
+ listSource: z.string().describe("Where the blessed list is read from, so a self-hosted sandbox can show its own."),
92
+ listReadAt: z.string().optional().describe("When that list was last read. Absent means it has never been reachable from here."),
93
+ });
94
+ export type EnginesView = z.infer<typeof EnginesViewSchema>;
95
+
96
+ export const EngineChannelInputSchema = z.object({
97
+ id: EngineIdSchema.describe("Which engine."),
98
+ kind: z.enum(["blessed", "latest", "pinned", "image"]).describe("Where its version should come from."),
99
+ version: z.string().optional().describe("Which version, required when pinning and ignored otherwise."),
100
+ });
101
+
102
+ /* Install and activate a version now. `version` absent means what the channel offers, which is the button on
103
+ * the row. Naming one explicitly is the "update anyway" path: it installs a version the blessed list does not
104
+ * name, which is how a sandbox gets past an upstream version floor that the list has not caught up with. */
105
+ export const EngineUpdateInputSchema = z.object({
106
+ id: EngineIdSchema.describe("Which engine."),
107
+ version: z
108
+ .string()
109
+ .optional()
110
+ .describe("Which version. Leave it out for whatever the channel offers; naming one takes a version nobody has blessed, deliberately."),
111
+ /* The other half of the update-anyway path: a turn that died on an upstream version floor knows the floor
112
+ * and not which published version satisfies it. Sending the floor lets the daemon answer that — it takes
113
+ * the LOWEST version at or above it, the smallest step that works — rather than making a browser walk a
114
+ * registry to fill in a version number. */
115
+ floor: z
116
+ .string()
117
+ .optional()
118
+ .describe("Install the lowest published version at or above this one. What a turn refused for being too old sends back."),
119
+ });
120
+ export const EngineRevertInputSchema = z.object({ id: EngineIdSchema.describe("Which engine.") });
121
+
122
+ export const EngineAppliedSchema = z.object({
123
+ ok: z.literal(true).describe("It went through."),
124
+ version: z.string().describe("Which version is now active."),
125
+ source: z.enum(["image", "store"]).describe("Whether that is the image's copy or the store's."),
126
+ // An engine loaded in-process (the Claude SDK's JavaScript half, @cursor/sdk) is picked up by the NEXT
127
+ // turn rather than by the one that pressed the button: a turn already running holds the module it loaded.
128
+ fromNextTurn: z.boolean().describe("Whether the change reaches turns already in flight, or only the next one."),
129
+ });