@intentic/sandbox-contract 1.227.0 → 1.228.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentic/sandbox-contract",
3
- "version": "1.227.0",
3
+ "version": "1.228.1",
4
4
  "description": "oRPC wire contract for the intentic sandbox daemon, shared by the daemon and its browser client",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -89,16 +89,16 @@
89
89
  "@orpc/contract": "1.14.13",
90
90
  "tslib": "2.8.1",
91
91
  "zod": "4.4.3",
92
- "@intentic/constants": "1.227.0",
93
- "@intentic/registry": "1.227.0",
94
- "@intentic/extension-manifest": "1.227.0"
92
+ "@intentic/constants": "1.228.1",
93
+ "@intentic/extension-manifest": "1.228.1",
94
+ "@intentic/registry": "1.228.1"
95
95
  },
96
96
  "devDependencies": {
97
97
  "@types/node": "24.13.2",
98
98
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
99
99
  "vitest": "4.1.10",
100
- "@intentic/testing": "0.0.0",
101
- "@intentic/tsconfig": "0.0.0"
100
+ "@intentic/tsconfig": "0.0.0",
101
+ "@intentic/testing": "0.0.0"
102
102
  },
103
103
  "scripts": {
104
104
  "build": "tsgo",
@@ -39,9 +39,9 @@ describe("every provider/harness pair declares what it can do", () => {
39
39
 
40
40
  // Nothing may be left to inference: a `permissions` a surface can't read, or a runtime nobody serves,
41
41
  // is the drift this record exists to end.
42
- expect(["claude-code", "codex", "opencode", "opencode-gemini", "acp", "pi"]).toContain(capabilities.runtime);
42
+ expect(["claude-code", "codex", "cursor", "opencode", "opencode-gemini", "acp", "pi"]).toContain(capabilities.runtime);
43
43
  expect(["modes", "plan"]).toContain(capabilities.permissions);
44
- expect(["full", "browser", "http", "none"]).toContain(capabilities.mcp);
44
+ expect(["full", "tools", "browser", "http", "none"]).toContain(capabilities.mcp);
45
45
  // Every runtime executes SOMETHING: a record listing no backend would hide the shell every loop has.
46
46
  expect(capabilities.execution).toContain("shell");
47
47
  for (const backend of capabilities.execution) {
@@ -30,6 +30,10 @@ export const PROVIDERS: readonly { label: string; value: NativeProvider }[] = [
30
30
  // and that channel vends Claude and GPT-OSS models alongside Gemini's own (see gemini-models.ts). A section
31
31
  // headed "Gemini" holding Claude Opus would be a lie; "Google" is what the whole list has in common.
32
32
  { label: "Google", value: "gemini" },
33
+ // Cursor's own agent runtime, driven through the SDK Anysphere publishes, on the user's Cursor subscription.
34
+ // Like Google above, the label names the ACCOUNT rather than a model family: the channel vends Anthropic,
35
+ // OpenAI and xAI models alongside Cursor's own Composer, and no model name covers that list.
36
+ { label: "Cursor", value: "cursor" },
33
37
  ];
34
38
 
35
39
  // What it COSTS to unlock a provider, and what the user connects to do it, the axis the picker groups on, since
@@ -52,6 +56,11 @@ export const PROVIDER_ACCESS: Record<NativeProvider, ProviderAccess> = {
52
56
  grok: { kind: "subscription", requirement: "SuperGrok subscription", runs: "Grok" },
53
57
  kimi: { kind: "subscription", requirement: "Kimi Code subscription", runs: "Kimi Code" },
54
58
  gemini: { kind: "free", requirement: "Google sign-in", runs: "Gemini, Claude and GPT-OSS under Claude Code" },
59
+ // A `subscription` like the first four, and the requirement names the PLAN rather than the account, because
60
+ // a free Cursor account signs in perfectly and still cannot run a turn here: the SDK behind this provider is
61
+ // gated to the paid tiers. Saying "Cursor account" would send someone to a sign-in that ends in a refusal
62
+ // they had no way to predict.
63
+ cursor: { kind: "subscription", requirement: "Cursor Pro subscription", runs: "Cursor Agent" },
55
64
  };
56
65
 
57
66
  /* THE PROVIDERS THAT COST NOTHING, derived from the table above rather than named a second time, and read by
@@ -59,14 +68,14 @@ export const PROVIDER_ACCESS: Record<NativeProvider, ProviderAccess> = {
59
68
  *
60
69
  * The distinction is worth the export. `accessBadge` answers "what does this row cost" for a row the user is
61
70
  * already looking at; this answers "which row should a user who has connected nothing be shown FIRST", which is
62
- * the connect gate's whole job. Ranking the one free channel fifth among five equal buttons is how a user with
71
+ * the connect gate's whole job. Ranking the one free channel last among equal buttons is how a user with
63
72
  * no subscription concluded the product needed one. Deriving the list keeps that promotion honest: a channel
64
73
  * that stops being free stops being promoted, from one edit to PROVIDER_ACCESS. */
65
74
  export const FREE_PROVIDERS: readonly NativeProvider[] = NATIVE_PROVIDERS.filter((provider) => PROVIDER_ACCESS[provider].kind === "free");
66
75
  export const isFreeProvider = (provider: AgentProvider): boolean => FREE_PROVIDERS.includes(provider as NativeProvider);
67
76
 
68
77
  /* WHOSE ALLOWANCE A TURN ON THIS PROVIDER SPENDS, as the subject of a sentence, a third naming of the same
69
- * five ids, and the third is not redundancy. PROVIDERS names the RUNTIME the user picks ("Claude Code", "Kimi
78
+ * six ids, and the third is not redundancy. PROVIDERS names the RUNTIME the user picks ("Claude Code", "Kimi
70
79
  * Code") and PROVIDER_ACCESS.requirement names the thing they CONNECT ("Claude subscription", "Google sign-in");
71
80
  * neither reads as English in "… usage limit reached", and neither is what a spent quota belongs to.
72
81
  *
@@ -80,6 +89,10 @@ export const PROVIDER_VENDOR: Record<NativeProvider, string> = {
80
89
  grok: "xAI",
81
90
  kimi: "Kimi Code",
82
91
  gemini: "Google",
92
+ // The plan that gets billed is Cursor's, whichever vendor's model actually answered. A Cursor turn on Claude
93
+ // Opus spends Cursor's included usage and Anthropic has no part in it, the same reasoning that makes a
94
+ // `gemini` turn say "Google" above.
95
+ cursor: "Cursor",
83
96
  };
84
97
 
85
98
  // What a turn on this provider costs at the MARGIN, ordering the same three kinds by the only question a
@@ -190,8 +203,8 @@ export const HARNESSES: readonly { label: string; value: AgentHarness }[] = [
190
203
 
191
204
  /* WHAT A PROVIDER/HARNESS PAIR CAN ACTUALLY DO, one declaration, read by both sides of the wire.
192
205
  *
193
- * Five runtimes serve turns behind one seam (AgentRequest in, AgentEvent frames out): the Claude Code Agent SDK
194
- * loop, Codex app-server, OpenCode, any ACP agent, and Pi's RPC surface. They do NOT do the same things, and for a long time
206
+ * Six runtimes serve turns behind one seam (AgentRequest in, AgentEvent frames out): the Claude Code Agent SDK
207
+ * loop, Codex app-server, OpenCode, Cursor's own loop run in-process, any ACP agent, and Pi's RPC surface. They do NOT do the same things, and for a long time
195
208
  * the only thing that said so was a comment inside each adapter, "Ignores the Claude-only request fields",
196
209
  * which no surface above it could read. So the composer offered "Ask before each file edit" on a runtime whose
197
210
  * every tool call is pre-approved, and offered a reasoning-effort scale to a runtime that drops the field.
@@ -217,7 +230,7 @@ export interface AgentCapabilities {
217
230
  // `opencode-gemini` is the OpenCode loop pointed at Gemini rather than at xAI, and it is a SEPARATE runtime
218
231
  // id from `opencode` on purpose: adapter health is keyed by this field (adapter-health.ts), so sharing one
219
232
  // would make Grok's xAI credential decide whether the picker greys out Gemini, and the reverse.
220
- readonly runtime: "claude-code" | "codex" | "opencode" | "opencode-gemini" | "acp" | "pi";
233
+ readonly runtime: "claude-code" | "codex" | "opencode" | "opencode-gemini" | "acp" | "pi" | "cursor";
221
234
  // Mid-turn injection (the SteeringQueue behind /agent/steer). Needs the SDK's streaming-input mode.
222
235
  readonly steering: boolean;
223
236
  // How much of the permission-mode axis the runtime honours. "modes" = every PermissionMode, with per-tool
@@ -226,12 +239,20 @@ export interface AgentCapabilities {
226
239
  readonly permissions: "modes" | "plan";
227
240
  // Can stop mid-turn and ask the user a multiple-choice question (`question` frames).
228
241
  readonly questions: boolean;
229
- // Which of the turn's tools reach the agent. "full" = http MCP tools + in-process SDK servers + plugin
230
- // checkouts + the browser servers; "browser" = the process-backed browser servers alone; "http" = the http
231
- // MCP tools alone, and only if the agent advertises http MCP support; "none" = the runtime has no seam for
232
- // them at all. Keeping the partial answers distinct matters: a runtime that can drive a connected account
233
- // must not be described as tool-less, and one that cannot host daemon-side SDK servers must not claim full.
234
- readonly mcp: "full" | "browser" | "http" | "none";
242
+ /* Which of the turn's tools reach the agent. "full" = http MCP tools + in-process SDK servers + plugin
243
+ * checkouts + the browser servers; "tools" = all of that EXCEPT plugin checkouts; "browser" = the
244
+ * process-backed browser servers alone; "http" = the http MCP tools alone, and only if the agent advertises
245
+ * http MCP support; "none" = the runtime has no seam for them at all. Keeping the partial answers distinct
246
+ * matters: a runtime that can drive a connected account must not be described as tool-less, and one that
247
+ * cannot host daemon-side SDK servers must not claim full.
248
+ *
249
+ * "tools" exists for the Cursor runtime and would have been a lie either way without it. Cursor's SDK takes
250
+ * stdio AND http/sse MCP servers, and its `customTools` run host callbacks in this process, which is the
251
+ * seam an in-process SDK server needs, so calling it "browser" would understate it by three whole
252
+ * categories. What it genuinely cannot host is a Claude Code PLUGIN checkout: that is a directory layout the
253
+ * Agent SDK loads, not a protocol, and no other runtime will ever read one. So the gap is real, permanent
254
+ * and worth its own word rather than being rounded to "full". */
255
+ readonly mcp: "full" | "tools" | "browser" | "http" | "none";
235
256
  /* WHICH EXECUTION BACKENDS THE RUNTIME HOSTS, the ways a turn RUNS things, as opposed to the tools it is
236
257
  * handed. "shell" is the runtime's own command tool (Bash on the Claude Code loop, each foreign loop's
237
258
  * equivalent); "js" is the sandbox's JavaScript backend (execution/ in the daemon): the model writes a
@@ -468,7 +489,7 @@ const ACP: AgentCapabilities = {
468
489
  secrets: "none",
469
490
  };
470
491
 
471
- /* THE PI CAPABILITY ID IS RESERVED, the same way the five native ids are: an `agent`-kind capability installed
492
+ /* THE PI CAPABILITY ID IS RESERVED, the same way the six native ids are: an `agent`-kind capability installed
472
493
  * under it is served over Pi's own RPC protocol rather than ACP. Pi closed ACP support deliberately (its RPC
473
494
  * mode is the embedding surface), and the two want different records. A bare id rather than a namespace like
474
495
  * `endpoint/`, because there is exactly one Pi runtime to name; capabilitiesOf still answers from the id alone,
@@ -504,6 +525,86 @@ const PI: AgentCapabilities = {
504
525
  secrets: "none",
505
526
  };
506
527
 
528
+ /* CURSOR'S OWN AGENT RUNTIME, driven through `@cursor/sdk`, the SDK Anysphere publishes, in this daemon's own
529
+ * process. The second-richest row in this file after the Claude Code loop, and the reason is the SDK rather
530
+ * than the vendor: it is an EMBEDDING surface, not a CLI wrapped in a pipe, so most of the seams the other
531
+ * foreign runtimes lack are simply function arguments here.
532
+ *
533
+ * WHY NOT THROUGH OPENCODE, which is already in this image and already serves two providers. Every Cursor
534
+ * bridge for OpenCode is a community reverse-engineering of Cursor's private agent RPC or a localhost shim
535
+ * around its CLI, and the OPENCODE record above is the weakest in this file. Routing Cursor through it would
536
+ * have capped a first-party SDK at Grok's ceiling and made the row depend on a third party's spare time.
537
+ *
538
+ * WHY THE HARNESS AXIS DOESN'T APPLY, the same way it doesn't for Gemini, and for the mirror-image reason.
539
+ * Gemini has no Claude Code route because Google refuses that traffic; Cursor has none because there is no
540
+ * translator route at all, CLIProxyAPI does not serve Cursor as a provider (asked for repeatedly upstream and
541
+ * closed as not planned), and Cursor publishes no OpenAI-compatible endpoint on a subscription. The SDK IS the
542
+ * only door, so `capabilitiesOf` answers this record whatever harness the client sent.
543
+ *
544
+ * The three axes below that read weaker than they could are deliberate, not unfinished: see the notes on each. */
545
+ const CURSOR: AgentCapabilities = {
546
+ runtime: "cursor",
547
+ // The SDK's Run can be cancelled but not written to mid-flight: a second `send` on a busy agent is an
548
+ // AgentBusyError, not an injection. So the steering queue has nowhere to go and the composer hides it.
549
+ steering: false,
550
+ /* Cursor's OWN plan mode (`mode: "agent" | "plan"`), not this repo's two-phase emulation, which is the
551
+ * better version of the same bargain: the model is put in a read-only posture by the vendor rather than
552
+ * being asked to behave.
553
+ *
554
+ * Not "modes", and that is the honest half. The hook seam below can gate shell, MCP, file reads and file
555
+ * edits, which is most of the tool surface but not all of it, and a per-tool posture with a silent gap in
556
+ * it is worse than one that says where it stops. */
557
+ permissions: "plan",
558
+ /* TRUE BECAUSE WE SUPPLY THE TOOL, not because Cursor's own askQuestion is wired. That one is put in
559
+ * `disallowedTools`: in a headless run it has been reported to answer itself with a fabricated "Questions
560
+ * skipped by the user", which is the single worst failure shape available here, an agent acting on consent
561
+ * nobody gave. The ask tool the daemon registers through `customTools` runs in this process, parks on a
562
+ * real card, and cannot invent an answer because it is the thing that receives one. */
563
+ questions: true,
564
+ // stdio + http/sse MCP servers, plus host callbacks through `customTools` (which is where the browser stack
565
+ // and the in-process SDK servers land). Everything but a Claude Code plugin checkout, see the axis note.
566
+ mcp: "tools",
567
+ execution: ["shell"],
568
+ /* Cursor publishes effort as MODEL PARAMETERS rather than as one scale (`ModelListItem.parameters` /
569
+ * `variants` → `ModelSelection.params`), so the shared tiers are mapped onto whatever the selected model
570
+ * declares, and a model that declares none simply offers no control. True here because the axis is
571
+ * forwardable at all; which tiers exist is the live catalog's answer, not this record's. */
572
+ effort: true,
573
+ fastMode: false,
574
+ /* "cwd", and this is the one place the SDK's in-process design costs something. A namespace is built around
575
+ * a CHILD the daemon spawns (that is how the Claude Code loop and Codex app-server get theirs); Cursor's
576
+ * loop runs inside the daemon, whose own /work must stay the shared checkout, so an isolated conversation
577
+ * gets its worktree by working directory and the turn is told where its tree is (turn-preamble.ts). */
578
+ isolation: "cwd",
579
+ // Cursor's commands are files on disk (`.cursor/commands`), which the SDK loads but does not publish back,
580
+ // so there is no list to hand the `/` popover.
581
+ commands: false,
582
+ // The SDK runs its shell in-process; there is no tmux session for the terminal panel to attach to.
583
+ terminals: false,
584
+ // The SDK throws typed errors (RateLimitError and friends) rather than dissolving a refusal into prose, so
585
+ // the adapter can file the coded frames auto-resume keys off.
586
+ recovery: true,
587
+ /* "append", the OpenCode answer, reached by a completely different road. There is no system-prompt argument
588
+ * on `Agent.create`; what there is, is the `beforeSubmitPrompt` hook, whose reply carries
589
+ * `additional_context` that is folded into the request. So the owner's prompt and the persona note DO reach
590
+ * the model, on top of Cursor's own base prompt, and nothing can replace that base. */
591
+ instructions: "append",
592
+ /* THE FULL HOOK TIER, the only foreign runtime that reaches it. Cursor reads `.cursor/hooks.json` in its
593
+ * local runtime, and `beforeShellExecution` answers with `allow` / `deny` / `ask` plus the messages that
594
+ * explain it, with `failClosed` available so a crashed gate blocks instead of waving the command through.
595
+ *
596
+ * What earns "hooks" rather than "approval" is that a HOLD can genuinely park: the hook is a process the
597
+ * daemon wrote, so it blocks on the card and the vendor is simply waiting on a script, exactly the shape
598
+ * that makes the Claude Code loop's PreToolUse hook able to stop and ask. The vendor never decides which
599
+ * calls to raise, either, which is the caveat the "approval" tier carries and this one does not. */
600
+ rulebook: "hooks",
601
+ /* "none", and structurally so, like every other foreign runtime. Masking needs a seam that rewrites what
602
+ * the model READS after a tool ran; Cursor's `afterShellExecution` fires with the output but its reply is
603
+ * discarded upstream, and `beforeReadFile` sees the content only to allow or deny it. Both are gates, not
604
+ * filters, so there is nothing here to substitute a reference back into. */
605
+ secrets: "none",
606
+ };
607
+
507
608
  // The pair → its record. An `endpoint/<id>` provider is a model API the user configured, driven BY the Claude
508
609
  // Code loop on either harness, so it gets that loop's full ceiling, which is the entire point of routing a
509
610
  // model through it rather than adopting a second runtime. The reserved `pi` id is the Pi coding agent on its
@@ -528,6 +629,13 @@ export const capabilitiesOf = (provider: AgentProvider, harness: AgentHarness):
528
629
  if (provider === "gemini") {
529
630
  return OPENCODE_GEMINI;
530
631
  }
632
+ // Cursor ignores the harness for the mirror of Gemini's reason: there is no route to it but its own SDK. No
633
+ // translator serves Cursor and Cursor publishes no model endpoint on a subscription, so "Cursor under Claude
634
+ // Code" names a road that does not exist. Answering this record whatever was asked for is what keeps that a
635
+ // fact of the catalog rather than a rule each surface has to remember.
636
+ if (provider === "cursor") {
637
+ return CURSOR;
638
+ }
531
639
  if (isEndpointProvider(provider)) {
532
640
  return CLAUDE_CODE;
533
641
  }
@@ -565,7 +673,9 @@ export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
565
673
  ? ["MCP tools only: no plugins or browser"]
566
674
  : capabilities.mcp === "browser"
567
675
  ? ["browser tools only: no plugins or other MCP tools"]
568
- : []),
676
+ : capabilities.mcp === "tools"
677
+ ? ["no plugins: every other tool reaches it"]
678
+ : []),
569
679
  ...(capabilities.execution.includes("js") ? [] : ["no code runs, its shell is the one way to execute"]),
570
680
  ...(capabilities.effort ? [] : ["no effort control"]),
571
681
  ...(capabilities.commands ? [] : ["no slash commands"]),
@@ -13,6 +13,7 @@ import {
13
13
  AgentResumeAfterOutageSchema,
14
14
  AgentSearchQuerySchema,
15
15
  AgentSearchResultSchema,
16
+ AgentsArchivedSchema,
16
17
  AgentsListSchema,
17
18
  AgentsMovedSchema,
18
19
  AgentsRemovedSchema,
@@ -233,7 +234,7 @@ export const agentsContract = {
233
234
  "The gentle counterpart to discarding. Commits whatever the conversation still has in progress onto its own branch, releases its working copy, and keeps the entry and the record. It leaves the live fleet and joins the archive. Refused for a conversation that is running.",
234
235
  })
235
236
  .input(AgentArchiveSchema)
236
- .output(AgentsMovedSchema),
237
+ .output(AgentsArchivedSchema),
237
238
  unarchive: oc
238
239
  .route({
239
240
  method: "POST",
@@ -0,0 +1,74 @@
1
+ import { oc } from "@orpc/contract";
2
+ import {
3
+ AccountIdSchema,
4
+ AccountListQuerySchema,
5
+ AccountRenameSchema,
6
+ CursorLoginCancelSchema,
7
+ CursorLoginStartSchema,
8
+ OauthAccountListSchema,
9
+ OauthAccountSchema,
10
+ OkSchema,
11
+ } from "../schemas.js";
12
+
13
+ /* Cursor subscription sign-in, the sandbox owns the credential and the platform never sees it, the same bargain
14
+ * claude.contract.ts strikes. What differs is who finishes the handshake.
15
+ *
16
+ * Claude's flow is start → the browser gets a code → `exchange` hands it back. Cursor's PKCE verifier is
17
+ * redeemable on its own, so it never leaves the daemon: `start` begins the flow, keeps the verifier in memory,
18
+ * polls Cursor until the browser completes the sign-in, mints a 90-day user key and writes it as an account.
19
+ * The caller opens the URL and then watches `accounts`, exactly as it does for a device login, which is why
20
+ * there is no `exchange` route here and a `cancel` one instead.
21
+ *
22
+ * The model catalog is deliberately absent, as it is for every provider: it is the one question they all answer
23
+ * identically, so it lives on the shared /providers/{provider}/models route (providers.contract.ts). */
24
+ export const cursorContract = {
25
+ start: oc
26
+ .route({
27
+ method: "POST",
28
+ path: "/cursor/login/start",
29
+ summary: "Begin connecting a Cursor account",
30
+ description:
31
+ "Hands back the page to sign in on. The sandbox finishes the handshake itself and stores the credential, so nothing has to be pasted back: watch the account list instead.",
32
+ })
33
+ .output(CursorLoginStartSchema),
34
+ cancel: oc
35
+ .route({
36
+ method: "POST",
37
+ path: "/cursor/login/cancel",
38
+ summary: "Abandon a Cursor sign-in",
39
+ description: "Stops waiting on a sign-in nobody completed. An abandoned attempt also expires on its own.",
40
+ })
41
+ .input(CursorLoginCancelSchema)
42
+ .output(OkSchema),
43
+ // Shaped like Claude's, including `force`, so the two account lists render through one component. Cursor
44
+ // publishes no account-wide headroom today, so `usage` stays absent on these rows and they draw a dot rather
45
+ // than a ring, which is what an unmeasured account has always meant here.
46
+ accounts: oc
47
+ .route({
48
+ method: "GET",
49
+ path: "/cursor/accounts",
50
+ summary: "Connected Cursor accounts",
51
+ description:
52
+ "Each connected account, and whether its stored key is still good. Cursor publishes no plan-wide allowance, so these rows carry no usage reading.",
53
+ })
54
+ .input(AccountListQuerySchema)
55
+ .output(OauthAccountListSchema),
56
+ rename: oc
57
+ .route({
58
+ method: "POST",
59
+ path: "/cursor/account/rename",
60
+ summary: "Rename a Cursor account",
61
+ description: "Changes the label one account shows under, so several are tellable apart.",
62
+ })
63
+ .input(AccountRenameSchema)
64
+ .output(OauthAccountSchema),
65
+ disconnect: oc
66
+ .route({
67
+ method: "POST",
68
+ path: "/cursor/account/disconnect",
69
+ summary: "Disconnect a Cursor account",
70
+ description: "Clears the stored key for one account. The others stay connected.",
71
+ })
72
+ .input(AccountIdSchema)
73
+ .output(OkSchema),
74
+ };
package/src/events.ts CHANGED
@@ -879,11 +879,16 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
879
879
  * is, so nothing is re-run and nothing asks the user to reconnect, the one recovery that looks
880
880
  * plausible and is guaranteed to waste their time. */
881
881
  "claude-not-entitled",
882
- // The model provider itself failed transiently: 500/502/503, a 529 at capacity, a dropped
883
- // socket, and the harness's own in-turn retries did not outlast it. Nothing about the workspace
884
- // or the request is wrong, so the daemon remembers the turn and re-runs it on an escalating
885
- // backoff (provider-health.ts): the frame is a notice about a turn that is coming back, and
886
- // reaches the client as a plain failure only once the attempts are spent.
882
+ /* The model provider itself failed transiently: 500/502/503, a 529 at capacity, a dropped
883
+ * socket, and the harness's own in-turn retries did not outlast it. Nothing about the workspace
884
+ * or the request is wrong, so the daemon remembers the turn and re-runs it on an escalating
885
+ * backoff (provider-health.ts): the frame is a notice about a turn that is coming back, and
886
+ * reaches the client as a plain failure only once the attempts are spent.
887
+ *
888
+ * ONE 4xx JOINS THEM, the provider refusing a request PARAMETER nothing here sends (its own
889
+ * cache-retention default, or one a proxy added). It wears a client error's status code and is
890
+ * still a provider fault: there is no request of the user's to fix, and the same send goes
891
+ * through moments later, so it recovers the same way (agent/failure-sentences.ts). */
887
892
  "provider-outage",
888
893
  // The platform-owned free-trial pool failed after its bounded key walk. Unlike provider-outage,
889
894
  // this is never auto-resumed: failed calls are refunded and the user's message is held to retry.
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { capabilitiesContract } from "./contracts/capabilities.contract.js";
8
8
  import { choresContract } from "./contracts/chores.contract.js";
9
9
  import { ciContract } from "./contracts/ci.contract.js";
10
10
  import { claudeContract } from "./contracts/claude.contract.js";
11
+ import { cursorContract } from "./contracts/cursor.contract.js";
11
12
  import { draftsContract } from "./contracts/drafts.contract.js";
12
13
  import { endpointsContract } from "./contracts/endpoints.contract.js";
13
14
  import { exitContract } from "./contracts/exit.contract.js";
@@ -46,6 +47,7 @@ export { capabilitiesContract } from "./contracts/capabilities.contract.js";
46
47
  export { choresContract } from "./contracts/chores.contract.js";
47
48
  export { ciContract } from "./contracts/ci.contract.js";
48
49
  export { claudeContract } from "./contracts/claude.contract.js";
50
+ export { cursorContract } from "./contracts/cursor.contract.js";
49
51
  export { draftsContract } from "./contracts/drafts.contract.js";
50
52
  export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
51
53
  export { exitContract } from "./contracts/exit.contract.js";
@@ -132,6 +134,7 @@ export const sandboxContract = {
132
134
  chores: choresContract,
133
135
  ci: ciContract,
134
136
  claude: claudeContract,
137
+ cursor: cursorContract,
135
138
  drafts: draftsContract,
136
139
  endpoints: endpointsContract,
137
140
  extensions: extensionsContract,
package/src/schemas.ts CHANGED
@@ -54,7 +54,7 @@ export const RepoParamSchema = z.object({
54
54
  // ACP (Agent Client Protocol).
55
55
  // Kept as a bare string on the wire (not an enum) so an unknown id is a clean error frame from the agent
56
56
  // route, the same bet RepoParamSchema makes, and adding an ACP agent needs no contract change.
57
- export const NATIVE_PROVIDERS = ["claude", "codex", "grok", "kimi", "gemini"] as const;
57
+ export const NATIVE_PROVIDERS = ["claude", "codex", "grok", "kimi", "gemini", "cursor"] as const;
58
58
  export type NativeProvider = (typeof NATIVE_PROVIDERS)[number];
59
59
  export const AgentProviderSchema = z.string().min(1);
60
60
  export type AgentProvider = z.infer<typeof AgentProviderSchema>;
@@ -1367,6 +1367,22 @@ export const AgentsMovedSchema = z.object({
1367
1367
  ),
1368
1368
  });
1369
1369
  export type AgentsMoved = z.infer<typeof AgentsMovedSchema>;
1370
+ /* AN ARCHIVE ALSO REPORTS WHAT IT COULD NOT DO, which is the half that used to go missing. Releasing a working
1371
+ * copy is git work, and it can fail for reasons no press can fix: the repository behind a checkout was deleted
1372
+ * from the workspace, a checkout is locked. Those agents stay on the board, so the answer carries them and the
1373
+ * sentence each failed with, otherwise the only true thing the caller could say about an archive that moved
1374
+ * nothing was "there was nothing to archive": to a user looking straight at the card it refused. */
1375
+ export const AgentsArchivedSchema = AgentsMovedSchema.extend({
1376
+ failed: z
1377
+ .array(
1378
+ z.object({
1379
+ id: z.string().describe("Which conversation stayed on the board."),
1380
+ reason: z.string().describe("Why its working copy could not be released, in the words the failure came with."),
1381
+ }),
1382
+ )
1383
+ .describe("The conversations this press could not put away, each with the reason, so the board can say it instead of reporting silence."),
1384
+ });
1385
+ export type AgentsArchived = z.infer<typeof AgentsArchivedSchema>;
1370
1386
  // What a purge actually deleted. Ids, not summaries: these agents no longer exist anywhere, there is nothing
1371
1387
  // left to show and nothing to put back, so the only thing the caller can do with the answer is drop those rows
1372
1388
  // and count them. No revision either: archived agents are already off the broadcast roster (see `list`), so a
@@ -1981,6 +1997,33 @@ export const AuthorizeChallengeSchema = z.object({
1981
1997
  verifier: z.string().describe("Keep this and send it back when finishing. It is what proves the code that comes back belongs to this handshake."),
1982
1998
  state: z.string().describe("The handshake's own id, sent back with it."),
1983
1999
  });
2000
+
2001
+ /* CURSOR'S SIGN-IN START. A third login shape, and the reason it is not one of the two above is where the
2002
+ * SECRET lives during the handshake.
2003
+ *
2004
+ * Claude's is paste-back: the browser receives a code and the caller hands it plus its verifier to `exchange`,
2005
+ * so the handshake's proof has to travel on the wire and AuthorizeChallengeSchema carries it. Cursor's PKCE
2006
+ * verifier must never leave the process that generated it, anyone holding it can redeem the login and mint a
2007
+ * durable key, so the daemon starts the whole flow, keeps the verifier in memory, polls Cursor itself, and
2008
+ * writes the account when it lands. Nothing redeemable is on this shape at all.
2009
+ *
2010
+ * Which makes it behave like a DEVICE flow from the caller's side (open the page, then watch the account list),
2011
+ * except that there is no one-time code to display: the login page is addressed to this handshake already. So
2012
+ * DeviceStartSchema's `code` would be a permanently blank field on every card, and TranslatorStartSchema's
2013
+ * `state` a value nothing sends back. `handshake` is neither, it is a cancellation handle. */
2014
+ export const CursorLoginStartSchema = z.object({
2015
+ url: z.string().describe("The page to open and sign in on. It is already addressed to this attempt, so there is no code to type."),
2016
+ handshake: z
2017
+ .string()
2018
+ .describe(
2019
+ "This attempt's id, for abandoning it. Not a credential and not redeemable: the proof that finishes the sign-in never leaves the sandbox.",
2020
+ ),
2021
+ expiresAt: z.number().describe("When this attempt stops being answerable, in milliseconds, so a card can stop waiting instead of spinning."),
2022
+ });
2023
+ export type CursorLoginStart = z.infer<typeof CursorLoginStartSchema>;
2024
+ // Abandon a sign-in nobody completed, so the daemon stops polling Cursor for it. Ordinary tidiness rather than
2025
+ // a security boundary: an unanswered attempt also times out on its own (see `expiresAt`).
2026
+ export const CursorLoginCancelSchema = z.object({ handshake: z.string().min(1).describe("Which attempt to stop waiting on.") });
1984
2027
  // xAI Grok (via OpenCode) uses subscription OAuth via the headless device-code method. `start` returns the
1985
2028
  // `url` the user opens (xAI's verification_uri_complete, which pre-fills the code) and `code`, the same
1986
2029
  // one-time code, surfaced so the card matches x.ai exactly. There is no paste-back: OpenCode polls to
@@ -4977,11 +5020,48 @@ export const EndpointConfigSchema = z.object({
4977
5020
  *
4978
5021
  * `gpu` mirrors the docker card's option and rides the same allowlisted `--gpus=all` directive: the ASK lives
4979
5022
  * here, what became of it is SANDBOX_GPU, stamped by the runner (see the docker handler's gpuState). "on"/"off"
4980
- * rather than a boolean for the manifest-wide reason DockerConfigSchema gives. */
5023
+ * rather than a boolean for the manifest-wide reason DockerConfigSchema gives.
5024
+ *
5025
+ * `context`/`contextTokens` are HOW MUCH CONVERSATION the server holds, the `model`/`url` pair's shape for the
5026
+ * same reason: a short list of rungs anyone can choose between, and one escape hatch for a person who knows the
5027
+ * exact number they want. Resolved to a single token count in exactly one place (the daemon's
5028
+ * endpoints/local-model.ts localModelWindow), because the flag llama-server is started with and the number the
5029
+ * card promises must never be two opinions. */
5030
+ export const LOCAL_MODEL_WINDOWS = ["16384", "32768", "65536", "131072"] as const;
5031
+ export type LocalModelWindow = (typeof LOCAL_MODEL_WINDOWS)[number];
5032
+
5033
+ /* THE RUNG A CARD WITH NO OPINION LANDS ON, and the one number in this block that is a product decision rather
5034
+ * than an arithmetic one.
5035
+ *
5036
+ * It is 65,536 because this sandbox runs a TOOL-CALLING AGENT LOOP, and that loop's own fixed cost, its
5037
+ * instructions plus one JSON schema per tool it can call, times every capability the owner has connected, is
5038
+ * tens of thousands of tokens before the user has typed anything (agent/context-budget.ts holds the measurement
5039
+ * and the refusal built on it). A window that cannot hold that cost is not a smaller version of the product; it
5040
+ * is a model whose every real turn is refused, which is what the previous flat 32,768 shipped: a 27B model,
5041
+ * seventeen gigabytes downloaded, and a first message that died on `36216 tokens exceeds 32768`.
5042
+ *
5043
+ * So the default is the smallest rung a full turn fits in, and the smaller rungs stay on the list because they
5044
+ * are honestly useful: pinned as the quick model (titles, commit messages) a window this size is waste, and the
5045
+ * gigabyte it gives back is the difference between running one of these models on an eight-gigabyte laptop and
5046
+ * not. What each rung costs in memory is the card's job to say (capability-catalog): roughly 2 GB of quantized
5047
+ * cache per 32k of window, on top of the weights. */
5048
+ export const LOCAL_MODEL_WINDOW_DEFAULT: LocalModelWindow = "65536";
5049
+
5050
+ /* THE BOUNDS ON THE TYPED NUMBER, and they are bounds against a TYPO rather than against a preference. Below
5051
+ * the floor there is no conversation left to have once the loop's own instructions land; above the ceiling is a
5052
+ * number no GGUF on offer was trained for, and llama-server would spend minutes reserving a cache for it before
5053
+ * failing. Everything between is the owner's call: their machine, their memory. */
5054
+ export const LOCAL_MODEL_WINDOW_MIN = 2048;
5055
+ export const LOCAL_MODEL_WINDOW_MAX = 1_048_576;
5056
+
4981
5057
  export const LocalModelConfigSchema = z.object({
4982
5058
  model: z.string().min(1),
4983
5059
  gpu: z.enum(["on", "off"]).default("off"),
4984
5060
  url: z.url().optional(),
5061
+ context: z.union([z.enum(LOCAL_MODEL_WINDOWS), z.literal("custom")]).default(LOCAL_MODEL_WINDOW_DEFAULT),
5062
+ // Coerced because it arrives from a text field as a string, the ssh card's `port` precedent, and only read
5063
+ // when `context` is "custom" (the `url`/`model` relationship exactly).
5064
+ contextTokens: z.coerce.number().int().min(LOCAL_MODEL_WINDOW_MIN).max(LOCAL_MODEL_WINDOW_MAX).optional(),
4985
5065
  });
4986
5066
  export type LocalModelConfig = z.infer<typeof LocalModelConfigSchema>;
4987
5067
  /* THE SANDBOX WALLET, a USDC balance the agent can spend on x402-payable endpoints, under owner policy.