@supermux/core 0.0.0-stage → 0.14.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.
Files changed (174) hide show
  1. package/API.md +570 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -2
  4. package/REFERENCE.md +242 -0
  5. package/dist/accounts/adapters/antigravity.d.ts +14 -0
  6. package/dist/accounts/adapters/antigravity.js +38 -0
  7. package/dist/accounts/adapters/claude.d.ts +7 -0
  8. package/dist/accounts/adapters/claude.js +67 -0
  9. package/dist/accounts/adapters/codex.d.ts +29 -0
  10. package/dist/accounts/adapters/codex.js +209 -0
  11. package/dist/accounts/adapters/cursor.d.ts +6 -0
  12. package/dist/accounts/adapters/cursor.js +34 -0
  13. package/dist/accounts/adapters/grok.d.ts +6 -0
  14. package/dist/accounts/adapters/grok.js +36 -0
  15. package/dist/accounts/adapters/index.d.ts +12 -0
  16. package/dist/accounts/adapters/index.js +25 -0
  17. package/dist/accounts/adapters/opencode.d.ts +12 -0
  18. package/dist/accounts/adapters/opencode.js +41 -0
  19. package/dist/accounts/adapters/shared.d.ts +8 -0
  20. package/dist/accounts/adapters/shared.js +40 -0
  21. package/dist/accounts/homes.d.ts +15 -0
  22. package/dist/accounts/homes.js +116 -0
  23. package/dist/accounts/index.d.ts +10 -0
  24. package/dist/accounts/index.js +8 -0
  25. package/dist/accounts/login.d.ts +77 -0
  26. package/dist/accounts/login.js +405 -0
  27. package/dist/accounts/policy.d.ts +20 -0
  28. package/dist/accounts/policy.js +56 -0
  29. package/dist/accounts/refresh.d.ts +20 -0
  30. package/dist/accounts/refresh.js +132 -0
  31. package/dist/accounts/registry.d.ts +66 -0
  32. package/dist/accounts/registry.js +355 -0
  33. package/dist/accounts/types.d.ts +214 -0
  34. package/dist/accounts/types.js +1 -0
  35. package/dist/accounts/usage-store.d.ts +31 -0
  36. package/dist/accounts/usage-store.js +139 -0
  37. package/dist/accounts/vault.d.ts +6 -0
  38. package/dist/accounts/vault.js +66 -0
  39. package/dist/acp/index.d.ts +74 -0
  40. package/dist/acp/index.js +1332 -0
  41. package/dist/acp/normalize.d.ts +48 -0
  42. package/dist/acp/normalize.js +916 -0
  43. package/dist/acp/opencode-server.d.ts +99 -0
  44. package/dist/acp/opencode-server.js +156 -0
  45. package/dist/acp/process.d.ts +42 -0
  46. package/dist/acp/process.js +162 -0
  47. package/dist/activity.d.ts +13 -0
  48. package/dist/activity.js +33 -0
  49. package/dist/agents/index.d.ts +47 -0
  50. package/dist/agents/index.js +489 -0
  51. package/dist/antigravity/files.d.ts +20 -0
  52. package/dist/antigravity/files.js +95 -0
  53. package/dist/antigravity/index.d.ts +41 -0
  54. package/dist/antigravity/index.js +539 -0
  55. package/dist/antigravity/normalize.d.ts +19 -0
  56. package/dist/antigravity/normalize.js +226 -0
  57. package/dist/antigravity/transport.d.ts +43 -0
  58. package/dist/antigravity/transport.js +129 -0
  59. package/dist/auth/index.d.ts +26 -0
  60. package/dist/auth/index.js +190 -0
  61. package/dist/claude/index.d.ts +39 -0
  62. package/dist/claude/index.js +769 -0
  63. package/dist/claude/normalize.d.ts +23 -0
  64. package/dist/claude/normalize.js +824 -0
  65. package/dist/claude/transport.d.ts +33 -0
  66. package/dist/claude/transport.js +158 -0
  67. package/dist/codex/catalog.d.ts +51 -0
  68. package/dist/codex/catalog.js +107 -0
  69. package/dist/codex/index.d.ts +45 -0
  70. package/dist/codex/index.js +1196 -0
  71. package/dist/codex/normalize.d.ts +34 -0
  72. package/dist/codex/normalize.js +874 -0
  73. package/dist/codex/transport.d.ts +33 -0
  74. package/dist/codex/transport.js +152 -0
  75. package/dist/configuration.d.ts +7 -0
  76. package/dist/configuration.js +36 -0
  77. package/dist/context/agents.d.ts +103 -0
  78. package/dist/context/agents.js +526 -0
  79. package/dist/context/index.d.ts +53 -0
  80. package/dist/context/index.js +272 -0
  81. package/dist/context/plugins.d.ts +29 -0
  82. package/dist/context/plugins.js +132 -0
  83. package/dist/context/types.d.ts +208 -0
  84. package/dist/context/types.js +1 -0
  85. package/dist/context/update.d.ts +33 -0
  86. package/dist/context/update.js +178 -0
  87. package/dist/core.d.ts +253 -0
  88. package/dist/core.js +1766 -0
  89. package/dist/environment/antigravity.d.ts +60 -0
  90. package/dist/environment/antigravity.js +336 -0
  91. package/dist/environment/claude.d.ts +4 -0
  92. package/dist/environment/claude.js +49 -0
  93. package/dist/environment/codex.d.ts +2 -0
  94. package/dist/environment/codex.js +80 -0
  95. package/dist/environment/credentials.d.ts +66 -0
  96. package/dist/environment/credentials.js +219 -0
  97. package/dist/environment/cursor-runtime.d.ts +25 -0
  98. package/dist/environment/cursor-runtime.js +109 -0
  99. package/dist/environment/cursor.d.ts +2 -0
  100. package/dist/environment/cursor.js +122 -0
  101. package/dist/environment/grok.d.ts +2 -0
  102. package/dist/environment/grok.js +111 -0
  103. package/dist/environment/index.d.ts +10 -0
  104. package/dist/environment/index.js +9 -0
  105. package/dist/environment/opencode.d.ts +2 -0
  106. package/dist/environment/opencode.js +56 -0
  107. package/dist/environment/spec.d.ts +4 -0
  108. package/dist/environment/spec.js +26 -0
  109. package/dist/environment/types.d.ts +83 -0
  110. package/dist/environment/types.js +1 -0
  111. package/dist/environment/write.d.ts +6 -0
  112. package/dist/environment/write.js +47 -0
  113. package/dist/errors.d.ts +8 -0
  114. package/dist/errors.js +17 -0
  115. package/dist/events/normalized.d.ts +308 -0
  116. package/dist/events/normalized.js +1 -0
  117. package/dist/events.d.ts +10 -0
  118. package/dist/events.js +34 -0
  119. package/dist/host/index.d.ts +107 -0
  120. package/dist/host/index.js +477 -0
  121. package/dist/host/provider.d.ts +15 -0
  122. package/dist/host/provider.js +44 -0
  123. package/dist/index.d.ts +18 -0
  124. package/dist/index.js +10 -0
  125. package/dist/keeper/client.d.ts +96 -0
  126. package/dist/keeper/client.js +672 -0
  127. package/dist/keeper/index.d.ts +5 -0
  128. package/dist/keeper/index.js +2 -0
  129. package/dist/keeper/keeper.d.ts +1 -0
  130. package/dist/keeper/keeper.js +648 -0
  131. package/dist/keeper/protocol.d.ts +147 -0
  132. package/dist/keeper/protocol.js +61 -0
  133. package/dist/keeper/socket.d.ts +12 -0
  134. package/dist/keeper/socket.js +22 -0
  135. package/dist/launch.d.ts +5 -0
  136. package/dist/launch.js +11 -0
  137. package/dist/mcp/brand.d.ts +4 -0
  138. package/dist/mcp/brand.js +2 -0
  139. package/dist/mcp/bridge.d.ts +1 -0
  140. package/dist/mcp/bridge.js +180 -0
  141. package/dist/mcp/host.d.ts +95 -0
  142. package/dist/mcp/host.js +440 -0
  143. package/dist/mcp/index.d.ts +12 -0
  144. package/dist/mcp/index.js +9 -0
  145. package/dist/mcp/server.d.ts +117 -0
  146. package/dist/mcp/server.js +172 -0
  147. package/dist/permissions.d.ts +25 -0
  148. package/dist/permissions.js +101 -0
  149. package/dist/private-files.d.ts +23 -0
  150. package/dist/private-files.js +166 -0
  151. package/dist/self-entry.d.ts +21 -0
  152. package/dist/self-entry.js +29 -0
  153. package/dist/session.d.ts +164 -0
  154. package/dist/session.js +1059 -0
  155. package/dist/store.d.ts +38 -0
  156. package/dist/store.js +326 -0
  157. package/dist/subagent-actions.d.ts +45 -0
  158. package/dist/subagent-actions.js +55 -0
  159. package/dist/supermux.d.ts +223 -0
  160. package/dist/supermux.js +260 -0
  161. package/dist/types.d.ts +537 -0
  162. package/dist/types.js +12 -0
  163. package/dist/unix-socket.d.ts +18 -0
  164. package/dist/unix-socket.js +53 -0
  165. package/dist/version.d.ts +5 -0
  166. package/dist/version.js +5 -0
  167. package/examples/console.mjs +107 -0
  168. package/examples/custom-driver.mjs +146 -0
  169. package/examples/lifecycle.mjs +92 -0
  170. package/examples/real-agents.mjs +96 -0
  171. package/package.json +106 -4
  172. package/skills/supermux-core/SKILL.md +205 -0
  173. package/skills/supermux-core/references/api.md +149 -0
  174. package/skills/supermux-core/references/events.md +49 -0
package/API.md ADDED
@@ -0,0 +1,570 @@
1
+ # @supermux/core API
2
+
3
+ Node.js **>= 22** ESM. Package `@supermux/core`; the unscoped `supermux-core` is an alias that re-exports it. Import compiled `dist/` after `tsc`.
4
+
5
+ This document describes **exported source contracts**. The in-tree broker is not this contract. Driver capabilities and unsupported operations are listed under Drivers.
6
+
7
+ ### TypeScript (NodeNext)
8
+
9
+ Use `module` / `moduleResolution` `NodeNext`. Add `@types/node` **22** as a **devDependency** and set `"types": ["node"]` (or `--types node`). Do **not** enable blanket `skipLibCheck` for this package. With TypeScript 6.x and those Node types, `skipLibCheck: false` typechecks the public surface. No source API change is required.
10
+
11
+ ## Package exports
12
+
13
+ | Subpath | Factory / types |
14
+ | --- | --- |
15
+ | `@supermux/core` | `createCore`, `supermux`, `defaults`, `Core`, `Session`, `CoreError`, `UnsupportedOperation`, `createHost`, `createHostProvider`, `fileVault`, `memoryVault`, `connectKeeper`, `detachedKeeperHooks`, `CORE_SUBCOMMANDS`, `requireCloseMode`, `requireAgentsCloseMode`, public types |
16
+ | `@supermux/core/acp` | `acp` |
17
+ | `@supermux/core/claude` | `claude` |
18
+ | `@supermux/core/antigravity` | `antigravity` |
19
+ | `@supermux/core/codex` | `codex` |
20
+ | `@supermux/core/cursor` | `cursor` (re-export of `@supermux/core/agents`) |
21
+ | `@supermux/core/agents` | `grok`, `opencode`, `cursor` |
22
+ | `@supermux/core/auth` | `copiedCredentials`, `withAuth` (**deprecated**, see Accounts) |
23
+ | `@supermux/core/accounts` | `fileVault`, `memoryVault`, `AccountRegistry`, `ensureHome`, `claudeLayout`, `codexLayout`, adapters, `pickAccount`, `score`, usage parsers, `RefreshCoordinator`, `refreshCodexToken`, `UsageStore`, login parsers + `defaultLoginRunner` |
24
+ | `@supermux/core/environment` | `prepareGrokEnvironment`, `prepareCodexEnvironment`, `prepareOpenCodeEnvironment`, `prepareCursorEnvironment`, `prepareAntigravityEnvironment`, credential helpers |
25
+ | `@supermux/core/mcp` | `mcpServer`, `tool`, `HostMcpServer`, `isHostMcpServer`, `toCallToolResult`, `bridgeEntry`, `socketPaths`, `BRIDGE_ENV` (see "Host MCP servers") |
26
+ | `@supermux/core/mcp-bridge` | the bridge script itself (`dist/mcp/bridge.js`, no dependencies); the core runs it for you |
27
+
28
+ Root public types include `ActivityNotice`, `ActivityPhase`, `CreateOptions`, `AdoptOptions`, `ResumeOptions`, `SessionContextUpdate`, `SessionConfiguration`, `DriverContext`, `CoreEvent`, `Observer`, `AgentDriver`, `AgentRuntime`, `SubagentSnapshot`, `SubagentDelivery`, `SubagentMessageDelivery`, `Host`, `HostHandle`, `HostRegistration`, `StopKeeperLimits`, and what `connectKeeper` takes and returns (`ConnectKeeperOptions`, `KeeperSpec`, `FrameShape`, `KeeperLimits`, `KeeperConnection`, `KeeperFrameEvent`, `KeeperWelcome`). Those keeper types, and `connectKeeper` with them, are `@experimental` (marked so in their JSDoc): they may change in any release before 1.0, a patch release included.
29
+
30
+ Three root exports for hosts that embed the core and for custom drivers:
31
+
32
+ - `CORE_SUBCOMMANDS` is `{ keeper: "__core-keeper", bridge: "__core-bridge" }`, for a host that ships as one `bun build --compile` binary. Such a binary has no `keeper.js` or `bridge.js` on disk, so the core starts those two helper processes as `<binary> <subcommand>`; the binary's entry point must run the keeper or the bridge module when its first argument is one of these.
33
+ - `requireCloseMode(options)` returns `options.mode` when it is `"shutdown"` or `"detach"` and throws a `TypeError` otherwise (`close mode is required`). It is the check every built-in runtime's `close` makes, for a custom driver's `close(options)` to make too.
34
+ - `requireAgentsCloseMode(options)` does the same for `options.agents` (`core close agents mode is required`): the check `core.close`, `host.close` and `HostProvider.close` make, for code that wraps one of them.
35
+
36
+ ## `supermux()` and `defaults`
37
+
38
+ `supermux(options)` is `createCore()` with every missing field filled in from `defaults`, an exported, deeply frozen object. `defaults` holds every default except Antigravity's `userHome`, which falls back to `os.homedir()` when it is not given. `createCore()` and the agent functions (`claude()`, `codex()` and the rest) stay strict: their required options have no defaults, and a missing one fails with an error naming it. None of the permission defaults is an auto-approve setting. Claude, Codex, Grok, OpenCode and Cursor use their CLI's own asking mode, so a request they need answered reaches you as a `permission-request` event. Antigravity has no prompt channel: in its `default` mode agy itself denies the actions its mode does not allow (a shell command, for example), and the core emits a `warning` event naming them when the turn ends.
39
+
40
+ ```ts
41
+ import { homedir } from "node:os"
42
+ import { join } from "node:path"
43
+ import { supermux, defaults } from "@supermux/core"
44
+
45
+ const core = supermux({
46
+ stateDirectory: join(homedir(), ".my-app"), // required; also holds each session's keeper
47
+ agents: { claude: {}, codex: { sandbox: "read-only" } },
48
+ limits: { interruptTimeoutMs: 5_000 }, // optional, merged over defaults.core.limits
49
+ })
50
+ console.log(defaults.codex) // every value supermux() fills in for Codex
51
+ ```
52
+
53
+ Keep `stateDirectory` outside every folder an agent can write (its working directory, `/tmp`, `$TMPDIR`): see REFERENCE.md, "Where the state directory lives". A session `cwd` that is the state directory or is inside it is always refused (`invalid_workdir`); `supermux()` also refuses one that contains it (`refuseWorkdirsContainingState`, `true` in `defaults.core`; pass `false` to allow it).
54
+
55
+ | Agent key | Permission input (default) | Required input |
56
+ | --- | --- | --- |
57
+ | `claude` | `permissionMode` (`default`), `permissionPrompts` (`host`) | none |
58
+ | `codex` | `sandbox` (`workspace-write`), `approvalPolicy` (`on-request`) | none |
59
+ | `grok` | `policy` (`ask`), `nativeMode` (`null`), `askKinds` (unset) | none |
60
+ | `opencode` | `policy` (`ask`), `nativeMode` (`null`), `askKinds` (unset) | none |
61
+ | `cursor` | `policy` (`ask`), `mode` (`agent`), `askKinds` (unset) | none |
62
+ | `antigravity` | `mode` (`default`) | `home`: a shadow HOME prepared with `prepareAntigravityEnvironment`; `userHome` defaults to `os.homedir()` |
63
+
64
+ Agents are named by key; a key left out is not available, and `{}` takes every default. A value you pass replaces the default, arrays included: Codex's `args` replaces `["app-server"]`, so keep `"app-server"` first for the real CLI. An option the agent's input does not list is refused (`supermux: agents.<name>: unknown option <key>`), typos included, and so is an unknown name in `limits`. `id`, `permissions` and Cursor's `nativeMode` are refused with a pointer to the input to use instead. `keeper` takes `{ parkedDeadlineMs, journalMaxBytes, connectTimeoutMs }` and merges one level over the agent's defaults; the keeper itself lives in the state directory, at `<stateDirectory>/keepers/<sessionId>` (its socket too, unless that path is longer than a unix socket path may be, 107 bytes on Linux and 103 on macOS: then the socket is a short path in a private per-user 0700 directory under the OS temp dir (else `/tmp`, else `$XDG_RUNTIME_DIR`, last because it is deleted at logout without lingering), recorded in the keeper's `status.json`; a recorded socket is only used when it is a socket of this user's in such a directory). `undefined` means not given, so the default applies; `null` is a value and is passed on to the driver. `refuseWorkdirsContainingState` defaults to `defaults.core.refuseWorkdirsContainingState` (`true`). Every other `CoreOptions` field (`profiles`, `accounts`, `onObserverError`, `context`, `contextPolicy`, `mcpServers`) passes through to `createCore()`. A relative `stateDirectory`, `home` or `userHome` is resolved against the process's working directory. For a driver of your own, or an option `supermux()` does not accept (such as Codex's `onRuntimeRequest`), call `createCore()` with driver instances. The option types `SupermuxOptions`, `SupermuxAgents`, `ClaudeInput`, `CodexInput`, `GrokInput`, `OpenCodeInput`, `CursorInput` and `AntigravityInput` are exported from the package root.
65
+
66
+ ## Core
67
+
68
+ ```ts
69
+ createCore(options: CoreOptions): Core
70
+ ```
71
+
72
+ `CoreOptions`: `stateDirectory` (required), `agents` (unique nonempty **ids**; the array may be empty), **`limits` (required, no defaults)**: `{ interruptTimeoutMs, maxPending, outstandingActivity }` each a **positive safe integer**. Missing `limits` or an invalid field → `TypeError` naming it. `profiles?`, `accounts?` (see Accounts), `onObserverError?`. `refuseWorkdirsContainingState?` (a boolean; absent: not checked; `supermux()` turns it on): also refuse a session `cwd` that contains the state directory (`invalid_workdir`), as one that is it or is inside it always is; leave it off only when agents must work in a folder above the state directory (a home directory, say), knowing they can write there. Permission answers are `session.requests.respond`, not a Core callback.
73
+
74
+ ### Configuration
75
+
76
+ Only `model`, `reasoningEffort` and `serviceTier` (nonempty strings). `serviceTier` is Codex's Fast tier id: Grok and Antigravity accept it and ignore it, and a driver without `configure` (Claude, ACP, Cursor, OpenCode) refuses it like any nonempty configuration (see below). Unknown keys / non-object → `invalid_input`. Core does not enumerate vendor effort strings; drivers may still reject (Grok: `low|medium|high` or a `TypeError` on open/configure).
77
+
78
+ Create/resume-open pass cloned requested state into `driver.open` when nonempty. On create/adopt, `{}` / omitted / undefined values → omit on disk and omit on `DriverContext`. Resume `configuration: {}` is an **explicit patch** (see table). **Adopt never opens**: nonempty adopt config is persisted only; `configure` capability is enforced on later resume-open, not at adopt.
79
+
80
+ | Call | omitted / `undefined` | `configuration: {}` | `undefined` values on keys |
81
+ | --- | --- | --- | --- |
82
+ | create | defaults | defaults (normalized away) | omitted |
83
+ | adopt | defaults | defaults | omitted |
84
+ | resume | no-options; dedupes in-flight restore | explicit patch; no join with a different restore (`session_busy`) | clears key |
85
+ | live idle resume + patch | n/a | `Session.configure` | same merge |
86
+
87
+ Nonempty create/resume-open config requires the opened runtime to advertise `configure` (**after** `driver.open`). Failure then leftover-cleans the runtime; if cleanup fails, retry `sessions.close(id, { mode: "shutdown" })`. Live patched resume without `configure` → `unsupported_operation` (Claude/ACP/Cursor/OpenCode).
88
+
89
+ `Session.configuration()` is requested persisted state (`{}` = defaults). `runtime.configuration()` is **optional** and driver-defined (live native **or** last requested overrides). A runtime may omit the getter.
90
+
91
+ ### `core.sessions`
92
+
93
+ - `create({ agent, cwd, id, authProfile?, account?, configuration?, permissions?, context?, contextPolicy? })` → `Session`. `permissions` (a `PermissionsSpec`) is the session's own: validated before anything launches (`invalid_input` with the validator's message), given to the driver ahead of the factory's spec and stored in the record. `context` / `contextPolicy`: see Session context. `account` xor `authProfile`. `id` is required (`TypeError` if missing). `cwd` must be an existing absolute directory that is not the state directory and is not inside it, nor, with `refuseWorkdirsContainingState` (on under `supermux()`), contains it, real paths compared (`invalid_workdir`; adopt, resume and fork check it too). Session id `^[a-zA-Z0-9_-]{1,128}$`.
94
+ - `adopt({ id, agent, agentSessionId, cwd, createdAt?, authProfile?, configuration? })` → `SessionRecord`. No spawn. Resume later must keep that native id.
95
+ - `get(id)`, `list({ agent? })`.
96
+ - `live(id)` → the open `Session` for `id` right now, or `undefined` (synchronous, no lifecycle work). After `account.switched` / `account.refreshed` / a context or host-tool reload it is the reopened Session, so a host holding the old object can pick up the new one.
97
+ - `reopening(id)` → `true` while the core itself is replacing the session's agent process (a context or host-tool reload, an account limit switch, a token refresh): the old `Session` is closing or closed and `live(id)` will return the new one. Lets a host tell a reload from an agent that died.
98
+ - `resume(id, { configuration?, account?, context?, adoptInstructions?, permissions? }?)` — exact native id. See table. `permissions` replaces the session's spec from this launch on: validated like `create`'s (`invalid_input`), given to the driver ahead of the record's spec and stored in the record, so a setting only a launch can apply (Codex's process-wide sandbox/approval policy) is right from the start; on a live session it is `session.setPermissions` instead. An agent still running behind a keeper keeps the spec it has (its live spec wins at re-attach): call `session.setPermissions` after the resume when it must change. `account` switches accounts (see Accounts). `context` (no `instructions`: fixed at creation) replaces the session's own skills, plugins and MCP servers (see Session context). `adoptInstructions` (string or strings): only for a record created before instructions were snapshotted (no `createdInstructions` and no own `context`, e.g. sessions a host made before it passed contexts): they become the session's instructions on this launch, snapshotted exactly as if given at creation, and are fixed from then on; ignored for every other record and when the driver cannot apply instructions.
99
+ - `updateContext(id, patch, options?)` → `{ applied, effective }` — see "Changing context in flight". On a session that is not open it only updates the record (the next launch applies it); on an open one it is `session.updateContext`.
100
+ - `forget(id)` — metadata only (not native erase): the record, and through `AgentDriver.forgetDetached(id)` what the driver keeps for the session (every built-in driver: its keeper directory; Antigravity also its `<state>/antigravity/<id>.json` and attachments; every registered driver when the record cannot be read or names an agent this core does not register). The session must already be closed: `session_busy` while it is open, and while its agent still runs behind its keeper (detached; `close(id, { mode: "shutdown" })` first), deleting nothing. Leftover ownership is `session_busy` until confirmed close.
101
+ - `close(id, { mode: "shutdown" | "detach" })` — `mode` is required (no default). No spawn/resume. Validates id (`invalid_session_id`). Joins in-flight same-id close **before** the shutdown gate (joining an already-running close still works after `core.close({ agents })` starts). A **new** close after shutdown → `core_closed`; `core.close({ agents })` finishes remaining leftovers. Waits already-started create/adopt/**fork**/resume (`opening` / restore; ignores setup rejection), then live `Session.close({ mode })` or leftover cleanup. With `shutdown` it then stops an agent still running for the session with no Session open on it (a detached session never resumed, e.g. after a host restart) through `AgentDriver.shutdownDetached(id)`: every built-in driver stops the session's keeper and its agent, launching nothing (no-op when none runs). Only a keeper whose socket takes a connection is signalled (its recorded pid may belong to another process by now): one whose socket does not answer fails the close with `keeper_unconfirmed`, also after the close stopped an open Session's agent, whose error then says that the agent was stopped but its keeper could not be confirmed gone, and to retry the close (or stop that keeper process); its `cause` is the keeper's own refusal. A record that cannot be read, or names an agent this core does not register, is handled by every registered driver; a core that cannot open its state directory (another core holds it) stops nothing, as before. `detach` on a session that is not open is a no-op. Does **not** abort a pending custom-driver `open`. Do not `await sessions.close(id, { mode })` from inside that same id’s `driver.open`. Fire-and-forget from `open` is fine. Unknown valid id is idempotent. Failed close keeps leftover; retry `sessions.close(id, { mode })` or `core.close({ agents })`. Different ids are independent. `detach` needs a runtime that advertises it: every built-in driver does, through its keeper, and a custom driver that does not rejects `unsupported_operation` (`detach`) before any side effect. A detached session’s record stays on disk with its native id so a later resume re-attaches.
102
+
103
+ Duplicate saved id → `session_exists`. Core closing → `core_closed` on **new** operations. Failed-open leftover blocks create/adopt/resume/forget of the same id until confirmed close.
104
+
105
+ ### `core.auth`
106
+
107
+ `methods` / `login` only if the driver implements `auth`. ACP/Grok expose ACP auth discovery.
108
+
109
+ ### `core.subscribe` / `core.close`
110
+
111
+ Events are live microtask notifications, not a durable log. ACP history load updates may set `replay: true`. Initialize metadata is **not** replay.
112
+
113
+ `CoreEvent` variants: `session.created` | `session.resumed` | `session.stateChanged` | `session.update` | `session.event` | `session.failed` | `message.accepted` | `message.started` | `message.completed` | `account.switched` | `account.exhausted` | `account.refreshed` | `account.unavailable` | `account.available` | `context.degraded` | `context.updated` | `tool.called` | `tool.finished` (host MCP server calls, see "Host MCP servers").
114
+
115
+ ## Normalized events
116
+
117
+ `session.event` is emitted beside each `session.update` (the raw update is unchanged). Envelope: `{ sessionId, agent, seq, ts, turnId?, replay, origin: "live"|"replay", native: { protocol, method?, payload } }`. `seq` is per-session monotonic, minted by Session. `origin` follows the update `replay` flag. `native.protocol` names the producing runtime (`AgentRuntime.nativeProtocol`): `claude-stream-json` (Claude), `codex-app-server` (Codex), `acp` (Grok/OpenCode/Cursor, including their vendor `_x.ai/…` frames), `antigravity-stream-json` (Antigravity), `core` for driver-synthesized `supermux/…` frames and Core's own bodies. Turn boundaries are Session state (`running` → `turn-start`, `idle` → flush then `turn-complete`); vendor `turn_completed` is ignored.
118
+
119
+ Body kinds: `turn-start`, `turn-complete`, `assistant-delta` / `assistant-message`, `reasoning-delta` / `reasoning` (`redacted: true` when the agent provided no text), `tool-call`, `command-output`, `file-diff`, `web-search`, `mcp-tool`, `plan`, `task`, `subagent`, `user-question`, `permission-request`, `request-resolved`, `permission-auto` (`{ toolCall, optionId }`: the session's ACP policy answered a request itself, so no `permission-request` was raised), `permissions-update` (`{ spec, applied }`: `session.setPermissions` changed the permissions, `applied` is `now` or `next-turn`), `commands-update`, `mode-update`, `session-info`, `usage`, `compaction`, `warning`, `error`. Unknown native frames produce no `session.event`. A `user-question` the driver asked the host and a `permission-request` are answerable via `session.requests`: Claude `AskUserQuestion`, Grok `_x.ai/ask_user_question` and Codex's inline `agentMessage.questions`, whose answer `respond()` steers into the running turn. A Codex `item/tool/requestUserInput` request is refused by the driver, so its `user-question` event is never pending.
120
+
121
+ `tool-call` carries card-ready fields (optional, additive): `input` (tool arguments: command/cwd/path/query/file changes/MCP args), `output` (aggregated command output, tool/MCP result text), `exitCode`, `description` (agent “why” when provided), `category` (`execute|edit|read|search|fetch|mcp|web-search|other|…`). Claude Code's Edit and Write, and the Codex and ACP mappers, also emit `file-diff` `{ callId, path, diff, changeKind? }` per changed file (a unified diff; a new file from Claude Code's Write, `changeKind: "add"`, is its content) and `command-output` deltas keyed by `callId`. Activity cards for Core agents are built from these fields only (not `native`).
122
+
123
+ **Subagents.** Every body may carry `subagentId`: set on bodies produced by a subagent (its tool calls, command output, text, reasoning, requests); main-thread bodies leave it unset and main-thread messages never contain subagent text. The `subagent` body `{ subagentId, phase, parentCallId?, name?, description?, prompt?, background?, activity?, stats?, result?, model?, messaging?, nativeId? }` has exactly one `started`, `progress` when activity/stats change, one terminal (`completed` | `failed` | `cancelled`) per run, and `resumed` (same id) when a finished subagent runs again. Ids: Claude `task_id`; Codex child `threadId`; Grok child session id; Cursor `subagentSessionId`/agent id (a Task resume's `<id>.2` session is `nativeId`); OpenCode the `task` call id (child session in `nativeId`, known once the task ends). `messaging`: `relay` (Claude SendMessage, Cursor Task resume), `direct` (Codex v1 child thread, Grok/OpenCode child session), `none` (Codex `multi_agent_v2`, which refuses direct input with -32600). `task` stays for shell/workflow/monitor/background-bash only. OpenCode shows no live child activity over ACP (only the finished task).
124
+
125
+ **Truthful subagent actions.** Every `subagent` body also carries what a client may do right now: `canMessage`, `canStop`, `actionsSource` (`native`: the agent reports it; `derived`: computed from that agent's own lifecycle signals by its own rules — never a generic state machine), and when an action is off a short user-facing `cannotMessageReason` / `cannotStopReason`. A `progress` body is emitted whenever the flags change outside a lifecycle body. Terminal bodies carry `endedBy`: `self` (finished on its own), `parent` (its parent model closed/killed it: Claude `TaskStop`, Codex `close_agent`/`interrupt_agent`, Grok `kill_command_or_subagent`) or `client` (`stopSubagent`). `name` is the real name when the agent has one (Codex `agentNickname` from `thread/read`; others `subagent_type`/Cursor name). A relayed message's outcome arrives as a body with `delivery: { status: "delivered" | "refused", reason? }` (a `SubagentDelivery`; Claude's SendMessage result; a refusal also turns `canMessage` off with Claude's reason). The `delivery` that `messageSubagent` settles is a `SubagentMessageDelivery`: a `SubagentDelivery` or `unconfirmed`. Per agent (truth table 2026-10-03):
126
+
127
+ <!-- subagent-truth-table:start (generated from scripts/subagent-truth/matrix.ts; do not edit by hand) -->
128
+ | | running | finished | stopped by client | ended by parent | after resume |
129
+ |---|---|---|---|---|---|
130
+ | Claude (derived) | Message (relay, queued for its next tool round) + Stop (`stop_task`) | Message (resumes) | **no Message** ("Stopped by you — Claude can't resume it"; Claude refuses, even after a restart) | Message (resumes) | as before the restart (registry) |
131
+ | Codex (native) | Message (`turn/steer`) + Stop (child turn active) | Message (`turn/start`) | Message | Message (`thread/resume` first: close_agent unloads it) | Message (`thread/resume`) |
132
+ | Codex v2 child | no Message ("Codex doesn't accept messages for this subagent", `canAcceptDirectInput: false`) | — | — | — | — |
133
+ | Grok ≥ 1.0.46 (derived) | **no Message** ("Grok can message it once it finishes": loading a running child breaks it) + Stop (`_x.ai/subagent/cancel`) | Message (`session/load` + `session/prompt`) | Message | Message | Message (registry keeps the child session) |
134
+ | OpenCode (derived) | Message + Stop (HTTP abort) once the child session is known | Message | Message | — (no parent kill tool) | Message (registry) |
135
+ | Cursor | Message (relay, derived; queued until the parent's Task returns) | Message | — | — | Message; Stop never ("Cursor can't stop subagents": `subagent_spawned.capabilities` is `{}`) |
136
+ <!-- subagent-truth-table:end -->
137
+
138
+ Antigravity has no row: its mapper emits no `subagent` bodies.
139
+
140
+ **Feature detection.** The undocumented controls behind some cells are checked when a session opens, so a CLI update that removes one turns the flag off with a reason instead of failing at click time: **Grok** — `_x.ai/subagent/cancel` is probed with an id no subagent has (1.0.46 answers `outcome.kind: "not_found"`; it advertises nothing in `initialize`); -32601 or an unknown reply shape turns Stop off for background children ("This Grok version can't stop subagents"; a child the client prompted itself is still stopped with `session/cancel`), and a -32601 at click time does the same and re-sends the flags. **Codex** — the catalog rewrite runs only when `codex debug models` still prints `{ models: [...] }` with `multi_agent_version` "v1"/"v2"; otherwise it is skipped with a `warning` body on the first turn, and children report their real protocol through `thread/read` `canAcceptDirectInput` (v2: "Codex doesn't accept messages for this subagent"). **OpenCode** — `GET /permission` is probed when the side channel starts; a route that answers OpenCode's web app (`200 text/html`), malformed JSON, or a 404 that is not OpenCode's `NotFoundError` (startup or any later call, including Stop) turns the side channel off for good: no more polling, Stop off ("This OpenCode version can't stop subagents"; "supermux can't reach this OpenCode session's server" when it has none), one `warning` that child permission requests are no longer relayed. **Cursor** — if `initialize` does not echo `sessionCapabilities.subagents` after the `_meta.subagents` opt-in, the Task call is the subagent (no live child stream) with `messaging: "none"`: no Message ("This Cursor version doesn't report its subagents": a relay needs the agent id) and no Stop. `bun run truth:subagents` (REFERENCE.md) re-checks the whole table live.
141
+
142
+ While a client's own direct message runs on a Grok/OpenCode child, Message is off ("It is still answering your last message"). `Session.subagents()` returns the folded registry, one plain object per subagent, as `SubagentSnapshot[]` (root export): `{ subagentId: string; status: "running" | "completed" | "failed" | "cancelled"; updatedAt: number /* epoch ms */; endedBy?: "self" | "parent" | "client"; name?: string; description?: string; background?: boolean; model?: string; messaging?: "direct" | "relay" | "none"; canMessage?: boolean; canStop?: boolean; actionsSource?: "native" | "derived"; cannotMessageReason?: string; cannotStopReason?: string; nativeId?: string; spawnCallId?: string; parentCallId?: string }`, the optional fields when known. It is saved next to the session record (`<stateDirectory>/subagents/<id>.json`) and handed back to the driver on resume (`DriverContext.subagents`; the driver returns its view as `AgentRuntime.restoredSubagents` — a fresh process reports what the old one ran as `cancelled`, a keeper re-attach keeps it running), so subagents stay addressable across restarts.
143
+
144
+ A driver maps its native updates through `AgentRuntime.normalize(update)`, which returns the normalized bodies of one update (none for a frame it does not know), and `AgentRuntime.flush()`, which returns the final messages of deltas still buffered; both are optional. Each built-in driver keeps a mapper of its own, with its own buffers. ACP `agent_message_chunk` / `agent_thought_chunk` flush to final messages on turn-complete.
145
+
146
+ `message.started` `{ sessionId, messageId }` fires once when an owned queued send becomes the active drain entry, **synchronously before** `runtime.prompt`, **after** `session.stateChanged` to `running`. Drain is blocked while outstanding native activity (`activity.size`); sequence is then `accepted` → wait activity complete → `started` → `completed`. Not emitted for cancelled queued work, autonomous `onActivity`, or an idempotent resend of the same key. Observer delivery is `queueMicrotask`; prompt may already be running.
147
+
148
+ `close({ agents: "shutdown" | "detach" })` is required (`agents` has no default). Aborts the core lifetime, waits operations, closes runtimes with that mode, then releases the store/lock. `{ agents: "detach" }` must not kill keeper-backed agents. Failed runtime shutdown: retry `close({ agents })`. A `.core.lock` left by a crashed core is reclaimed by the next core when the process it records is gone or it was written before the last boot. A lock whose owner may still run, or that cannot be read, fails the core's first operation with `state_locked`; remove it by hand only after proving its owner has exited.
149
+
150
+ ## Session
151
+
152
+ ## Requests
153
+
154
+ `session.requests.list(): PendingRequest[]`. Pending items are `kind: "permission"` (`permission-request` body) or `kind: "question"` (`user-question` body, `blocking: true`). `session.requests.respond(requestId, answer)`: permissions take `{ optionId, message? }` and resolve `{ outcome: { outcome: "selected", optionId }, message? }`; questions take `{ answers: Record<questionId, optionId | optionId[] | free text> }` (option ids are mapped to labels for the driver) or `{ decline: true }`. Wrong answer kind or unknown question id → `invalid_input`. Unknown/already-resolved id → `request_not_found`. The `user-question` event of a Codex `item/tool/requestUserInput` request (the driver refuses that request) is never listed: `respond` on its id is `request_not_found`, with message `non-blocking question: answer with session.send` when the event says `blocking: false`. A Codex inline question is listed like any other, and `respond` steers the answer into its running turn (`{ decline: true }` steers an explicit decline); if the turn ends first it resolves `cancelled` and nothing is steered. Driver AbortSignal / interrupt / close → cancelled + `request-resolved` `cancelled`. Cap is `limits.maxPending`; overflow is cancelled immediately plus a warning. `snapshot().pendingRequests` is the pending count.
155
+
156
+ - `id`, `snapshot()`, `capabilities()` (`detach` is true only for keeper-backed runtimes).
157
+ - `send({ content, whenBusy, idempotencyKey? })` → `Receipt`. Failures settle `completed` as `{ status: "failed", error }` rather than rejecting the receipt (invalid send still throws).
158
+ - `pending.list|cancel|clear|continue`. `continue` after `interrupt({ pending: "keep" })`.
159
+ - `interrupt({ pending })` → `stopped` | `already_idle` | `unconfirmed`. `pending: "discard" | "keep"` is required (`TypeError` if missing). No defaults.
160
+ - `close({ mode: "shutdown" | "detach" })` — `mode` is required. `shutdown` stops the native process (today’s previous close). `detach` drops this process’s connection and keeps the agent running (every built-in driver, through its keeper). Missing `mode` is a `TypeError`. `steer`, `configure`, `configuration()`, `history`, `fork({ id, at? })` (`id` required). Session.`detach()` remains unsupported as a separate method; use `close({ mode: "detach" })`.
161
+
162
+ Configure/fork require idle + empty queue + no outstanding activity. Configure merge: nonempty string `model` / `reasoningEffort` / `serviceTier` only; `undefined` values clear that key. Empty requested config means defaults.
163
+
164
+ `messageSubagent(subagentId, content, { whenBusy? })` → `{ via: "direct", delivery }` (delivered to the child now: Codex `turn/steer` while its turn runs, else `turn/start`; Grok/OpenCode `session/load` once then `session/prompt` on the child session, which streams back as that subagent and never drives the parent turn) or `{ via: "relay", receipt, delivery }` (Claude/Cursor: a main-thread message asking the parent model to forward it verbatim, queued through `send`). `delivery` settles (a `SubagentMessageDelivery`) `{ status: "delivered" }`, `{ status: "refused", reason }` (the agent's own refusal) or `{ status: "unconfirmed", reason }` (the relay turn ended without the parent forwarding it). `stopSubagent(subagentId)`: Claude `stop_task`, Codex `turn/interrupt` on the child's turn, Grok `_x.ai/subagent/cancel` (a background child; `session/cancel` on a child we prompted), OpenCode HTTP abort. Both throw `UnsupportedOperation` (`unsupported_operation`) when the runtime lacks them, `subagent_unavailable` (message = the agent's reason) when the subagent's current `canMessage` / `canStop` is false, `subagent_not_found` for an id the runtime does not know, and `runtime_closed` after the runtime closed. `subagents()` returns the folded registry described after the subagent truth table.
165
+
166
+ `updateContext(patch, options?)` changes the session's own context in flight (see "Changing context in flight"); after a reload the session continues in a new `Session` (`core.sessions.live(id)`).
167
+
168
+ Steer without an active owned prompt → `session_not_running`. Close/fail/interrupt on a closed or failed handle → `session_closed` / `session_failed`.
169
+
170
+ ## Types (selected)
171
+
172
+ `SessionRecord` version `1`. Secrets never stored. `AuthProfile` is `{ agent, env?, methodId?, unsetEnv?, args? }` in **caller** configuration. `SessionRecord.account?` (id only; never together with `authProfile`).
173
+
174
+ `AgentUpdate`: `{ protocol: "acp" | "native", value, replay? }`.
175
+
176
+ `ActivityNotice`: `{ id: string; phase: "started" | "completed" }` (`ActivityPhase`). Core copies each notice, so changing it afterwards has no effect. Empty id / bad phase silently dropped. Duplicate `started` same id: no-op. Unknown `completed`: no-op. At most `limits.outstandingActivity` distinct outstanding ids (open buffer and live map): the caller sets it in `createCore()`, and `supermux()` defaults it to 256. One more → session `fail` with `activity_overflow`. The ACP-based drivers (Grok, OpenCode, Cursor) also count their own outstanding activity against their `maxOutstandingActivity` option (256 under `supermux()`) and fail the session the same way. Native activity is independent of owned receipts and does **not** emit `message.started`. Not a `CoreEvent`; hosts observe busy via `snapshot().state` / interrupt / send rejection.
177
+
178
+ Drivers still receive `DriverContext.requestPermission` and `DriverContext.requestAnswers` (Session implements both). Hosts must not pass `onPermission` on `createCore`; they subscribe to `permission-request` / `user-question` events and call `session.requests.respond`. No library always-approve default.
179
+
180
+ ## Errors
181
+
182
+ Every `CoreError` has a `code`. These are all the codes a caller can see, from a rejected call, a receipt that settles `failed`, or a `session.failed` event.
183
+
184
+ **Input and options**
185
+
186
+ - `invalid_options`: an option of `createCore()`, `createHost()`, the accounts, a vault or an auth helper is invalid, or a path the core needs for a socket or the MCP secret is unusable. The message names it.
187
+ - `invalid_input`: an argument is wrong (an unknown configuration key, an answer of the wrong kind, an `optionId` the request does not have, a bad account or login option, and so on). The message says which.
188
+ - `invalid_session_id`: a session id is not `^[a-zA-Z0-9_-]{1,128}$`.
189
+ - `invalid_workdir`: `cwd` is not an existing absolute directory, is the state directory or inside it, or, under `supermux()` (`refuseWorkdirsContainingState`), contains it.
190
+ - `invalid_auth_profile`: the profile is not one of the core's `profiles`, or belongs to another agent.
191
+ - `unknown_agent`: no agent with that id is registered.
192
+
193
+ **Sessions**
194
+
195
+ - `session_exists`: a session with that id is already saved. A record stays after `close`; only `forget` removes it.
196
+ - `session_busy`: the session is occupied. Another lifecycle operation on its id is running, or its failed-open leftover is not closed yet; or it is not idle (`configure`, `fork`, a `whenBusy: "reject"` send, a context change); or `forget` finds it open or its agent still running behind its keeper.
197
+ - `keeper_unconfirmed`: a session's keeper did not answer on its socket either way, so the core could not tell whether it still runs and signalled nothing (its recorded pid may belong to another process by now). `sessions.close(id, { mode: "shutdown" })` rejects with it; when that close had already stopped an open session's agent, the message says so (the agent was stopped, but its keeper could not be confirmed gone) and to retry the close, and `cause` is the keeper's own refusal. A launch that has to replace a running agent started for another context (`resume` with a changed context) fails with it too. `forget` is `session_busy` instead.
198
+ - `session_not_found`: no saved session has that id.
199
+ - `session_closed`: the `Session` (or a host handle) is closed.
200
+ - `session_failed`: the `Session`'s runtime failed; close it and resume it.
201
+ - `session_not_running`: `steer` with no active turn to steer.
202
+ - `queue_full`: the session's input queue already holds `limits.maxPending` messages.
203
+ - `idempotency_conflict`: an `idempotencyKey` was used again with different input.
204
+ - `request_not_found`: no pending request has that id.
205
+ - `core_closed`: a new operation after the core began closing.
206
+ - `resume_identity_changed`: a resume came back on another native conversation than the saved one.
207
+ - `session_identity`: a driver re-attached to, or resumed, an agent whose native session is not the one the record names (ACP, Grok, Antigravity).
208
+ - `fork_identity_unchanged`: the agent reused the source conversation instead of forking.
209
+ - `activity_overflow`: more distinct outstanding native activities than the limit (see `ActivityNotice`); the session fails.
210
+ - `unsupported_operation`: the agent or the platform does not support the operation; the message says which. `UnsupportedOperation` extends `CoreError` with this code.
211
+
212
+ **State directory**
213
+
214
+ - `state_locked`: the state directory's `.core.lock` is held by a process that may still run (another core), or cannot be read. A lock left by a process that is gone is reclaimed (see `core.close`).
215
+ - `state_unsafe`: a symbolic link, or a file or folder of another user, where the core or a keeper reads or writes in the state directory: a session record or the subagent lists' folder, a session's generated context, a keeper's directory, journal, status, token or fingerprint, Antigravity's files of a session, the `.core.lock`, the MCP host's secret or socket. Nothing there is followed through a link (`accounts/` aside: see REFERENCE.md "Where the state directory lives"); the message names the path.
216
+ - `invalid_session_record`: a saved session record cannot be read or is not valid. It is never treated as empty.
217
+ - `store_closed`: the session store was used after the core released it.
218
+
219
+ **Accounts**
220
+
221
+ - `unknown_account`: no account has that id.
222
+ - `account_exists`: an account with that id exists, or the login is a second copy of a rotating login that another account or the system login already holds (refresh tokens are single-use).
223
+ - `account_expired`: a Codex token account's login expired and cannot be refreshed (it has no refresh token, or the server refused it).
224
+ - `account_secret_missing`: the account's secret is not in the vault.
225
+ - `account_home_conflict`: an account home, or an entry in it that must become a link, already exists and is not empty.
226
+ - `account_refresh_failed`: refreshing a Codex token failed for another reason than an expired login (a request or HTTP error, a reply without an access token), or another process holds the refresh lock.
227
+ - `account_identity_mismatch`: a guided login signed in as another email than the one asked for.
228
+ - `login_failed`: a guided login produced no usable login: the CLI exited with an error (the message has the exit code and the end of its output), wrote no credential file, or its identity cannot be read.
229
+ - `login_timeout`: a guided login took longer than `login.timeoutMs`.
230
+ - `login_cancelled`: a guided login was cancelled, with `cancel()` or by `core.close`.
231
+ - `invalid_account_id`: an account id is not `^[a-zA-Z0-9_-]{1,128}$`.
232
+ - `invalid_account_record`: the saved accounts file cannot be read.
233
+
234
+ **Context and host tools**
235
+
236
+ - `invalid_context`: a malformed context or patch, a path that is not an existing absolute directory, a bad or duplicate MCP server name, two different host server objects with one name, or a patch that removes what the session does not own or adds what it has.
237
+ - `context_unsupported`: policy `"error"` and the agent cannot apply an item; the message lists each one.
238
+ - `missing_mcp_servers`: a launch names a host MCP server that is not registered with this core; it is never dropped silently.
239
+ - `mcp_socket_in_use`: another live core owns this state directory's MCP socket.
240
+
241
+ **Subagents**
242
+
243
+ - `subagent_not_found`: unknown subagent id.
244
+ - `subagent_unavailable`: the agent says the action cannot work now; the message is its short reason.
245
+
246
+ **Drivers**
247
+
248
+ - `setup_timeout`: an ACP agent (also Grok, OpenCode, Cursor) did not finish starting within `setupTimeoutMs`; the open fails.
249
+ - `protocol_version`: an ACP agent answered `initialize` with another protocol version than the one the library speaks; the open fails.
250
+ - `connection_closed`: the connection to an ACP agent closed: the open fails, or the session fails if it was ready.
251
+ - `aborted`: an ACP or Grok operation was cut short because the core or the runtime was closing (a `core.auth` call in flight when the core closes, say). A session open that a close interrupts rejects `core_closed` instead.
252
+ - `auth_method`: the `methodId` of a profile or of `core.auth.login` is not an authentication method the ACP agent advertises.
253
+ - `conversation_lost`: Antigravity no longer has the conversation a resume names (its history was deleted, or it belongs to another account): a failed resume, or a failed session when it is found at a later launch.
254
+ - `runtime_closed`: a driver runtime was used after it closed.
255
+ - `busy`: an ACP-based driver (Grok's wrapper included) was given a prompt while one is already running.
256
+
257
+ **Host**
258
+
259
+ - `already_live`: `register` for an id that is already live.
260
+ - `host_closing`: the host is closing or closed.
261
+
262
+ **Auth helper only**
263
+
264
+ These four apply when using `copiedCredentials`, not the session surface.
265
+
266
+ - `auth_home_locked`: the agent home is already leased; verify its owner before recovering a stale lock.
267
+ - `auth_source_locked`: the credential source is busy; retry the auth cleanup.
268
+ - `auth_missing`: the credential file is unavailable.
269
+ - `auth_invalid`: the credential file does not hold a nonempty JSON object.
270
+
271
+ Errors a driver throws that are not `CoreError`s (Grok's `TypeError` on an effort it does not know, say) are passed on as they are, not rewritten into these codes.
272
+
273
+ ## Drivers
274
+
275
+ **ACP** `acp({ id, command, args, inheritEnv, mcpServers, keeper, setupTimeoutMs, shutdownTimeoutMs, maxFrameBytes, maxOutstandingActivity, cancelRetryIntervalMs, cancelRetryTimeoutMs, captureStderr, permissions, env?, classifyActivity?, vendor?, normalizer?, sessionConfig?, contextAdapter?, openCodePollIntervalMs? })`. **There are no defaults** for required fields; `acp()` throws `TypeError` naming a missing field. `captureStderr` forwards the agent's stderr lines as native updates `{ method: "stderr", params: { line } }` so a normalizer can map logged errors. Optional (absence means the field is not used): `env`; `classifyActivity`, which maps a forwarded update to a native activity notice; `vendor`, which switches on one agent's own handling: `"grok"` (its `_x.ai/ask_user_question` requests and `_x.ai` subagent notifications), `"cursor"` (it advertises the ACP subagents extension, `clientCapabilities._meta.subagents`, and reads `cursor/task`) or `"opencode"` (its `task` tool is the subagent; this needs `openCodePollIntervalMs`); `sessionConfig`, a `Record<string, string>` of select options (OpenCode's `model`, say) that `acp()` sets with `session/set_config_option` right after `session/new`, `session/resume` or `session/load`; `contextAdapter`, how the agent applies a session context (absent: MCP servers only, unverified; `grok()`, `opencode()` and `cursor()` pass their own); `openCodePollIntervalMs`, required with `vendor: "opencode"` and ignored by other vendors: how often the HTTP side channel is polled for subagent asks while work is live; and `normalizer`, an ACP normalizer to use instead of the private one `acp()` makes (the Grok wrapper keeps one across respawns; the package exports no way to build one). `permissions` is `{ kind: "acp", policy: "auto-approve" | "ask" | "read-only", nativeMode: string | null, askKinds?: ToolKind[] }`. Live `session.setPermissions` is allowed while a turn is running (`applied: "now"`). `auto-approve` answers the first `allow_*` option and emits `permission-auto` instead of `permission-request`; `read-only` rejects edit/execute/delete/move. `nativeMode` non-null uses `session/set_mode` when advertised, else `session/set_config_option { configId: "mode" }`. `keeper` is `{ stateDirectory, limits: { parkedDeadlineMs, journalMaxBytes, connectTimeoutMs } }`. The driver talks to the agent only through that keeper (same process-death / re-attach model as Codex). Client capabilities `{}` (no FS/terminal claims; `vendor: "cursor"` adds `_meta: { subagents: true }`). Steer/fork/configure/history unsupported at this wrapper; `detach: true`. Cancel retries are best-effort; core may still return `unconfirmed`.
276
+
277
+ **Grok** `grok({ id, command, commandArgs, permissions, noLeader, inheritEnv, mcpServers, keeper, setupTimeoutMs, shutdownTimeoutMs, maxFrameBytes, maxOutstandingActivity, cancelRetryIntervalMs, cancelRetryTimeoutMs, env?, model?, reasoningEffort?, authPath? })`. Required fields have **no defaults**; `grok()` throws `TypeError` naming a missing field. Optional (absent = flag/field not sent): `model`, `reasoningEffort`, `authPath`, `env`. Grok never passes `--always-approve`; policy is the ACP `permissions` spec. Configure: `close({ mode: 'shutdown' })` then reopen with **exact** `agentSessionId`. Steer/history/fork unsupported; detach is passed through to the child.
278
+
279
+ **OpenCode** `opencode({ id, command, inheritEnv, mcpServers, keeper, setupTimeoutMs, shutdownTimeoutMs, maxFrameBytes, maxOutstandingActivity, cancelRetryIntervalMs, cancelRetryTimeoutMs, openCodePollIntervalMs, permissions, env?, model? })` → `opencode acp`. Required fields have no defaults; `openCodePollIntervalMs` is how often the HTTP side channel is polled for subagent state. Optional: `env`, `model` (ACP `model` config option after session open). Steer/fork/configure/history unsupported at this wrapper; detach follows ACP. Provider failures arrive as normalized `error` events from stderr.
280
+
281
+ **Claude** `claude({ id, command, args, keeper, inheritEnv, tools, permissionPrompts, permissions, partialMessages, setupTimeoutMs, requestTimeoutMs, shutdownTimeoutMs, maxFrameBytes, env?, model?, effort?, fastMode?, allowedTools?, disallowedTools? })`. **There are no defaults** for those required fields; `claude()` throws `TypeError` naming a missing field. `permissions` is `{ kind: "claude", permissionMode: "bypassPermissions" | "acceptEdits" | "default" | "plan" | "auto" | "dontAsk" }` and is sent at open as `--permission-mode` (omitted when `default`). Live change uses `control_request` `{ subtype: "set_permission_mode", mode }` (`applied: "now"`). `--permission-prompt-tool stdio` stays on whenever `permissionPrompts` is `'host'`. The driver always passes `--allow-dangerously-skip-permissions`: without it a runtime `set_permission_mode` to `bypassPermissions` fails with `bypass_not_launched` (verified on Claude 2.1.291). It only makes bypass switchable at runtime; it does not enable it. Optional (absent = do not pass the CLI flag): `model`, `effort`, `fastMode`, `allowedTools`, `disallowedTools`, `env`. `fastMode: true` passes `--settings {"fastMode":true}`, which turns on Claude's Fast mode: a `--print` session counts as the Agent SDK, which needs this opt-in through the flag settings. `supermux()` accepts `fastMode` too. The launch permission mode comes only from `permissions` (there is no separate top-level `permissionMode`). `keeper` is `{ stateDirectory, limits: { parkedDeadlineMs, journalMaxBytes, connectTimeoutMs } }`. Frames are `claude-control`. Steer/fork/configure/history unsupported; `detach: true`.
282
+
283
+ **Codex** `codex({ id, command, args, keeper, sandbox, approvalPolicy, permissions, inheritEnv, setupTimeoutMs, requestTimeoutMs, shutdownTimeoutMs, maxFrameBytes, env?, model?, reasoningEffort?, onRuntimeRequest? })`. **There are no defaults** for those required fields; `codex()` throws `TypeError` naming a missing field. `permissions` is `{ kind: "codex", approvalPolicy: "never" | "on-request" | "untrusted", sandbox: "read-only" | "workspace-write" | "danger-full-access" }`. The top-level `sandbox` and `approvalPolicy` must equal `permissions.sandbox` and `permissions.approvalPolicy`; `codex()` throws `TypeError` when they differ. Live `setPermissions` is stored and sent on every following `turn/start` and `thread/resume` (`applied: "next-turn"`). The session's policy is also passed per process as `app-server -c sandbox_mode=… -c approval_policy=…` (appended after `args` and the profile's args): child threads spawned by the collab tools take their policy from the process config, and nothing is written into `CODEX_HOME` (an account can share it between sessions). After a live `setPermissions`, children spawned by the running process keep the launch policy until the next launch. There is no `permissionPrompts` option: command, file-change and MCP tool approvals go to the host's `requestPermission` (declined instead when they do not belong to the running turn). Exceptions: under `approvalPolicy: "never"` an MCP tool approval is accepted without asking the host; a free-form MCP elicitation is always declined; `item/permissions/requestApproval` is always answered with an empty grant. Optional (absent = not sent): `model`, `reasoningEffort`, `env`, `onRuntimeRequest`. Supports resume, steer, fork, configure, history, and `close({ mode: "detach" })`.
284
+
285
+ **Cursor** `cursor({ id, command, commandArgs, permissions, inheritEnv, mcpServers, keeper, setupTimeoutMs, shutdownTimeoutMs, maxFrameBytes, maxOutstandingActivity, cancelRetryIntervalMs, cancelRetryTimeoutMs, env?, model?, mode? })` → `cursor-agent acp`. Required fields have **no defaults**. `permissions` is an ACP `PermissionsSpec` (never `--force` / `--auto-review`). Optional `mode` (`agent` \| `plan` \| `ask`) is sent via ACP `session/set_config_option` after session/new or session/load; a `permissions.nativeMode` that differs (the factory's or a session's own) is applied after it, so the mode the permissions report is the one that runs. `model` is matched against the picker's values, then its names (`"auto"` → Cursor's `default[]`; a free plan accepts only Auto). Resume via `session/load`. Steer/fork/configure/history unsupported at this wrapper; `detach: true`. `captureStderr` is always on.
286
+
287
+ **Antigravity** `antigravity({ id, command, args, inheritEnv, permissions, home, setupTimeoutMs, shutdownTimeoutMs, maxFrameBytes, interruptGraceMs, keeper, env?, model? })` → one long-lived `agy --input-format stream-json --output-format stream-json` process per session, behind the keeper, for many turns. Required fields have **no defaults**; `antigravity()` throws `TypeError` naming a missing field, and `setupTimeoutMs`, `shutdownTimeoutMs`, `maxFrameBytes` and `interruptGraceMs` must be positive safe integers. `args` come before agy's own flags. `keeper` is `{ stateDirectory, limits: { parkedDeadlineMs, journalMaxBytes, connectTimeoutMs } }`; the driver talks to agy only through it, and also keeps `<keeper.stateDirectory>/antigravity/<sessionId>.json` (the conversation id and whether the instructions were sent). `home` is `{ shadow, user }` (`supermux()` takes them as the strings `home` and `userHome`): `shadow` is a shadow HOME prepared with `prepareAntigravityEnvironment` from `@supermux/core/environment` (see Environment), `user` is the real HOME it mirrors (the library only reads it, but agy itself writes its conversations into the real `~/.gemini/antigravity-cli` through the shadow's `antigravity-cli` link); agy runs with `HOME` set to `shadow`. `interruptGraceMs` is how long an interrupted agy may take to end its turn before it is shut down: an interrupt is SIGINT through the keeper, agy ends the turn and exits, and the next prompt starts it again with `--conversation <id>`. `permissions` is `{ kind: "antigravity", mode: "bypass" | "acceptEdits" | "plan" | "default" }`, sent at launch as `--dangerously-skip-permissions`, `--mode accept-edits`, `--mode plan` and no flag. A live `setPermissions` or `configure` restarts an idle process (after the running turn, when there is one), so `setPermissions` reports `applied: "next-turn"`; `configure` takes `model` only (`reasoningEffort` is ignored: agy's effort is part of the model id). There is no prompt channel, so no `permission-request` is ever emitted: agy itself denies the actions its mode does not allow, and the core emits a `warning` body (`source: "antigravity"`) naming the denied actions when the turn ends. A resume names the exact conversation (`--conversation <id>`); when agy no longer has it (its history was deleted, or it belongs to another account) that is `conversation_lost`: a failed resume, or a failed session when it is found at a later launch. A prompt takes text and images (each image is written under `<keeper.stateDirectory>/antigravity/<sessionId>/attachments` and referenced as `[Attached image: <path>]`); any other block kind, or an empty prompt, is `invalid_input`. Session instructions are sent once, as a leading `<session-instructions>` text block of the first prompt of a new conversation. Optional (absent = not sent): `model`, `env`. Frames are `antigravity-stream-json`. Steer/fork/history unsupported; `detach: true`.
288
+
289
+ Env precedence: inherit process (unless `inheritEnv: false`) → factory `env` → `profile.env`.
290
+
291
+ ## Host
292
+
293
+ Process-level owner of **one** `Core` for a single agent id. Consumers register per-session env/command/args (cloned; **never** written into session records), then `start` / `resume` / `stop`. No library defaults: `limits` and every close `mode` / `agents` are required.
294
+
295
+ ```ts
296
+ createHost({
297
+ stateDirectory,
298
+ limits, // CoreLimits, required
299
+ agent, // driver id
300
+ driver: (registered, context) => AgentDriver | Promise<AgentDriver>,
301
+ prepare?: (registration) => Promise<void | { env?, args?, context? }>, // after admission, before open; may return env/args the driver gets and the session context
302
+ accounts?, // AccountsOptions passed to the Core, e.g. one shared { registry, usage } for every agent's host
303
+ context?, // DriverContextSupport: what the drivers this host builds apply (as AgentDriver.context; a built-in driver carries its own: claude({ … }).context)
304
+ contextPolicy?, // the Core's default context policy ("error" | "warn")
305
+ mcpServers?, // HostMcpServer[] registered with the Core up front (createCore({ mcpServers }))
306
+ detached?, // { shutdownDetached?, forgetDetached? } for sessions.close(id, { mode: "shutdown" }) / forget; default: the built-in drivers' (keepers, Antigravity's files) in stateDirectory
307
+ }): Host
308
+ ```
309
+
310
+ **Session context through a host (C3).** `HostOptions.context` declares the host driver's context support exactly like a createCore driver's `AgentDriver.context` (absent: none, and the host behaves as before). A registration's `context` (or the one `prepare` returns, which replaces it for this and later opens) is the session's: on a new session it is the `create` context (instructions snapshotted); on an existing record its skills / plugins / MCP servers replace the session's own (`resume(id, { context })`), and its instructions are passed as `adoptInstructions`, so a record from before instructions were snapshotted takes them once and every other record keeps its creation snapshot. An adopted native session (`nativeSessionId`, no record yet) gets the same. The driver factory never sees the context: the core applies it through `DriverContext.sessionContext`. A registration without a context launches exactly as before. The ready handle follows every core-side reopen (`session.resumed`, `account.switched`, `account.refreshed`). `HostOptions.mcpServers` registers host MCP servers with the host's Core, so a context may name them as `{ kind: "host", name }` and a stored record that names one resumes after a host restart (a host created without it fails such a resume with `missing_mcp_servers`).
311
+
312
+ **Detached sessions through a host.** The host's driver forwards `HostOptions.detached` as its `shutdownDetached` / `forgetDetached`: the host cannot ask the drivers its factory builds (one per open, from a registration). By default it does what the built-in drivers do in the host's own `stateDirectory`, where their keepers live when the factory passes that directory as `keeper.stateDirectory`: it stops the session's keeper, and forget deletes its keeper directory and Antigravity's own files of the session. Keepers elsewhere: `detached: detachedKeeperHooks(theirDirectory, limits)`.
313
+
314
+ `HostRegistration`: `{ id, env, command?, args?, extra?, account?, context? }`. `account` on a new session creates it on that account (absent, or the agent's system account: no account in the record, today's behaviour). On an existing record a different account switches it on this open (`account.switched`, reason `manual`); absent keeps the record's account. `prepare` receives the **effective** account in `registration.account` (the record's when the registration has none), so it can skip credential copies; the driver factory sees it per open in `DriverContext.account` (also after Core-internal switches). A Core-internal reopen (limit switch, token refresh) updates the ready handle's `session`. `register` throws `host_closing` or `already_live` / `session_busy` (id already `admission` | `starting` | `ready` | `recovering`). A `failed-cleanup` id (start failed and the leftover process refused to die) may be re-registered: the new handle's `start` retries that cleanup first.
315
+
316
+ `HostHandle.start({ cwd, configuration?, permissions?, nativeSessionId?, onOpened? })` / `resume({ configuration?, permissions? })` / `stop({ mode })`. `permissions` is passed to `create({ permissions })` for a new session and to `resume(id, { permissions })` for an existing or adopted one (absent: a new session launches under the spec the driver factory builds). `onOpened(session)` runs inside the start (e.g. persist the native id): its rejection is a failed start and the session is closed. Lifetime fence: a stale handle cannot start/resume after replacement; concurrent `start` calls on one handle join the same in-flight open; `stop` is idempotent after confirmed release and a **no-op on a handle that never owned the id** (it never closes a replacement's session); a failed stop leaves the id `failed-cleanup` (retry `stop` on the same handle, or re-register and `start`, which retries the cleanup first); replacement is allowed only after confirmed stop. `handle.session` is the Core `Session` once started. `host.core` is for `subscribe`.
317
+
318
+ Admission fence is built in. `failed-cleanup` is retried **before** `prepare` (credential/config/home writes). Concurrent same-id starts from different handles: exactly one wins, the other rejects `session_busy` ("already awaiting failed-start cleanup" / "already starting") without closing anything. Recovering-token semantics match the former broker session fence.
319
+
320
+ `host.close({ agents })` fences every handle, closes Core, then stops handles. Failed Core close leaves the registry for retry.
321
+
322
+ ```ts
323
+ createHostProvider({ create: () => Host })
324
+ ```
325
+
326
+ `get()` is lazy and once. `close({ agents })` keeps the handle on failed close for retry and forbids reopen after shutdown. `setFactoryForTests` refuses to replace a live host. `resetForTests` is test-only.
327
+
328
+ ## Session context
329
+
330
+ Extra **instructions**, **skills**, **plugins** and **MCP servers** for a session, applied at launch through channels that are private to the session: nothing is written into the workdir or into the user's agent homes.
331
+
332
+ ```ts
333
+ type SessionContext = {
334
+ instructions?: string | string[] // appended to the agent's own system prompt; entries joined with a blank line
335
+ skills?: string[] // absolute folders holding <name>/SKILL.md
336
+ plugins?: string[] // absolute plugin folders
337
+ mcpServers?: (ExternalMcpServer | HostMcpServer | HostMcpServerRef)[]
338
+ // external: { name, command, args?, env? } stdio; name ^[A-Za-z0-9_-]+$
339
+ // host: a mcpServer() object (tools are TS functions in this process), or { kind: "host", name } for a registered one
340
+ }
341
+ createCore({ …, context?, contextPolicy?: "error" | "warn" }) // default for every session
342
+ core.sessions.create({ …, context?, contextPolicy? })
343
+ core.sessions.resume(id, { context? }) // context: Omit<SessionContext, "instructions">; replaces the session's own skills/plugins/MCP servers
344
+ session.fork({ id, at?, context? }) // inherits the parent's own context, policy and instructions; `context` (no instructions) replaces the rest
345
+ core.capabilities(agent).context // { instructions, skills, plugins, mcpServers }: { support, note }
346
+ ```
347
+
348
+ - **Merge.** Core default first, then the session's own: instructions (at creation only, see below) and lists are concatenated in that order. Duplicate MCP server names → `invalid_context`. Duplicate paths are applied once. Skills folders and plugins must be existing absolute directories (checked at every launch).
349
+ - **Persistence.** The record keeps the session's **own** context (`record.context`), its `contextPolicy` when set, and `createdInstructions`. The core default's skills, plugins and MCP servers are not stored: they are merged in again at each launch. Records without these fields load unchanged.
350
+ - **Honesty.** Before anything launches, the merged context is checked against the driver (`AgentDriver.context`). With `contextPolicy: "error"` (default) any item the agent cannot apply → `context_unsupported` and nothing launches (no process, no record, no folder). With `"warn"` the session launches without those items and the core emits `{ type: "context.degraded", sessionId, dropped: [{ kind, item, reason }] }` right after `session.created` / `session.resumed`. `"unverified"` items count as supported; `core.capabilities(agent)` lists them so a host can decide.
351
+ - **Instructions are fixed at creation, for every agent.** At create the merged instructions (core default + session) are snapshotted into `record.createdInstructions` (only when applied). Every later launch (resume, reload, keeper relaunch, OpenCode config regeneration) and every fork uses that snapshot and never re-merges, so a changed core default reaches only new sessions and never makes an existing session's resume fail. `resume(id, { context })`, `fork({ context })` and `updateContext` take no `instructions` (`invalid_context` "instructions are fixed when the session is created"). Several agents could not change them anyway: Claude stores its appended prompt with the session (`--resume` ignores a new one, verified on 2.1.289), Codex fixes `developerInstructions` at `thread/start`, Grok `_meta.rules` at `session/new`.
352
+ - **Live sessions.** `resume(id, { context })` with a context that differs from the stored one relaunches an idle session (same native id); during a turn it is `session_busy`. The same context returns the live session.
353
+ - **Files.** Generated files live in `<stateDirectory>/context/<id>/` (0700), rebuilt before each launch and deleted by `sessions.forget(id)`. No context at all (no default, none on the session) → no folder and a launch that is byte-for-byte what it was before (tested per driver). Antigravity is the exception: its `mcp_config.json` and `supermux-*` plugins go into its shadow HOME and are rewritten at every launch, also without a context (which clears what an earlier launch generated).
354
+ - **Drivers** get the merged context as `DriverContext.sessionContext: { instructions?, skills, plugins, mcpServers, directory, launch: "create" | "resume" | "fork", dropped, fingerprint }` and must skip `dropped` items. Custom drivers declare support with `AgentDriver.context: { capabilities, drops?(context), update? }`; a driver without it supports nothing. `createHost` drivers declare it with `HostOptions.context` (see Host).
355
+ - **Detached sessions.** The built-in drivers hand `fingerprint` (a digest of what the launch applies; `"none"` without a context) to the keeper (the `fingerprint` field of the `spec` that `connectKeeper` takes). A detached agent process whose recorded fingerprint differs is shut down and a new one started, never re-attached: `resume(id, { context })` after a host restart really relaunches. A keeper with no recorded fingerprint (started before C1b) is re-attached as before.
356
+
357
+ | Agent | Instructions | Skills | Plugins | MCP servers |
358
+ |---|---|---|---|---|
359
+ | Claude | `--append-system-prompt-file <ctx>/instructions.md`; a host's own `--append-system-prompt[-file]` is copied in first (Claude keeps only the last one) | one generated plugin per folder, inside the plugin folder | one `--plugin-dir <ctx>/plugins` holding a symlink per plugin | `--mcp-config <ctx>/mcp.json` (adds to a host's; no `--strict-mcp-config` added) |
360
+ | Codex | `thread/start.developerInstructions` (create only) | `skills/extraRoots/set` before `thread/start` / `thread/resume` | **mapped**: `skills/` → extraRoots, `.mcp.json` stdio servers → MCP; hooks, commands, agents and non-stdio servers are dropped | `app-server -c mcp_servers.<n>.{command,args,env}` + `default_tools_approval_mode="approve"`: per process, nothing written to `CODEX_HOME`; the session's approval policy is unchanged for every other tool |
361
+ | Grok | ACP `session/new` `_meta.rules` (create only) | one generated plugin per folder, `grok agent --plugin-dir` | `grok agent --plugin-dir <plugin>` each | ACP `mcpServers` (after the driver's own) |
362
+ | OpenCode | `OPENCODE_CONFIG=<ctx>/opencode.json` → `instructions` | same file → `skills.paths` (the global config's paths are repeated: this key replaces, it does not merge) | **mapped**: `skills/` → `skills.paths`, `.opencode/plugins/*` → `plugin`, `.mcp.json` stdio servers → ACP `mcpServers`; hooks, commands, agents dropped | ACP `mcpServers` |
363
+ | Cursor | a separate embedded-resource block in front of the first prompt's blocks (`supermux://instructions`, `text/markdown`, `annotations.audience: ["assistant"]`; sent once while the conversation has no turn: create, a `session/load` that replayed no user message, a keeper re-attach with it pending). Its echo is stripped from every `user_message_chunk` (Cursor flattens it into the replayed first message). Cursor's ACP server has no system-prompt channel (`--system-prompt` is ignored under `acp`); subagents do not get it | **unsupported** (`cursor-agent acp` reads skills only from the workspace and `$HOME/.cursor/skills` / `$HOME/.agents/skills`, which belong to the host) | **unsupported** (`cursor-agent acp` ignores `--plugin-dir`) | ACP `mcpServers` (subagents get them) |
364
+ | Antigravity | a leading `<session-instructions>` text block on the first prompt of a new conversation (agy has no system-prompt flag, and no `GEMINI.md` is written); `--conversation` keeps it | one generated `supermux-skills-N` plugin per folder, in the shadow HOME's `~/.gemini/config/plugins` | **mapped**: a generated `supermux-<name>` plugin in the same folder with the plugin's `skills/` and its `.mcp.json` stdio servers; hooks, commands and agents are dropped, and so is a plugin with neither `skills/` nor a stdio server | the shadow HOME's `~/.gemini/config/mcp_config.json`, under `supermux-<name>` (the user's own servers are kept; a name clash renames ours) |
365
+ | ACP (generic) | unsupported | unsupported | unsupported | unverified: ACP `mcpServers` |
366
+
367
+ A host's own `OPENCODE_CONFIG` is carried into the session file. A context MCP server whose name a driver's own `mcpServers` already uses is a drop. In a plugin's `.mcp.json`, `${CLAUDE_PLUGIN_ROOT}` is expanded for the mapping agents. Claude always gets the `--plugin-dir <ctx>/plugins` folder once a session has any context (empty is fine), so skills and plugins can be added live later. Live check: `bun scripts/context-live.ts [claude] [codex] [cursor] [grok] [opencode]` (scratch in `~/.cache/context-c1/`, or `CONTEXT_LIVE_DIR`; Cursor runs model Auto, `CONTEXT_LIVE_CURSOR_MODEL` overrides). Cursor proven 2026-10-05 (C0 cells: `docs/core-design/session-context.md` "Cursor cells as built").
368
+
369
+ ### Changing context in flight
370
+
371
+ ```ts
372
+ type ContextPatch = { // no instructions: fixed at creation
373
+ skills?: { add?: string[]; remove?: string[] }
374
+ plugins?: { add?: string[]; remove?: string[] }
375
+ mcpServers?: { add?: ExternalMcpServer[]; remove?: string[] } // remove by name
376
+ }
377
+ type UpdateContextOptions = {
378
+ reload?: "allow" | "never" // default "allow"; "never": items that need a relaunch are unsupported
379
+ holdOnCacheImpact?: boolean // Claude: reload_plugins refuses a reload that would change the tool list under a cached prompt
380
+ }
381
+ session.updateContext(patch, options?) → Promise<{
382
+ applied: Array<{ kind: "skills" | "plugins" | "mcpServers", op: "add" | "remove", item, how: "live" | "reload" | "unsupported", reason? }>
383
+ effective: "now" | "next_turn"
384
+ }>
385
+ core.sessions.updateContext(id, patch, options?) // same; a session that is not open only gets its record updated
386
+ core.capabilities(agent).contextUpdate // { skills|plugins|mcpServers: { add, remove, note } }
387
+ // event: { type: "context.updated", sessionId, applied }
388
+ ```
389
+
390
+ - **Validation** (`invalid_context`, nothing stored): the patch shape; an added path must exist and be new (also not in the core default); a removed path or server must be in the session's **own** context (the core default cannot be removed per session); nothing added and removed at once; MCP names unique. An `instructions` key → `invalid_context` ("instructions are fixed when the session is created"). An empty patch returns `{ applied: [], effective: "now" }`.
391
+ - **Per item:** `live` when the driver declares it and the running process confirms it (`AgentRuntime.context.live(change)`), otherwise `reload` (if `reload` is not `"never"`), otherwise `unsupported`. Under `contextPolicy` `"error"` (the session's, else the core's) anything unsupported → `context_unsupported` and **nothing** is applied or stored. Under `"warn"` the rest is applied and the unsupported items are reported (and not stored). A plugin part the agent cannot map (Codex hooks) is reported as its own unsupported row, like `context.degraded`.
392
+ - **Order:** the new own context is persisted to the record **before** anything reaches the agent (a crash never leaves the record older than what is live). A change the agent then refuses without effect (Claude `held: true`) becomes `unsupported` and is taken out of the record again.
393
+ - **Never mid-turn:** the core holds the queue (no queued input starts), waits until the session is idle, then applies. A running turn is never cancelled. `live` items are sent at that idle point (`effective: "now"` only when the call arrived while idle and every applied item was live). Any `reload` item relaunches the agent on the same conversation (the account-switch path: shutdown, then resume with the record's context); the other items of that update ride the relaunch and are reported `reload` too. Queued input moves to the relaunched session and runs after it, in order; the old `Session` object is closed, `core.sessions.live(id)` returns the new one. A live change that throws falls back to a reload (with the error in `reason`), or rethrows under `reload: "never"`. The promise settles once everything is applied.
394
+ - **Closed session:** `core.sessions.updateContext(id, …)` updates the record only; applicable items are `reload` (the next launch applies them), `effective: "next_turn"`; `reload: "never"` does not apply.
395
+ - **Concurrency:** updates of one session run one after another; during a resume/close/forget of the id → `session_busy`.
396
+
397
+ | Agent | Skills add / remove | Plugins add / remove | MCP add / remove |
398
+ |---|---|---|---|
399
+ | Claude | **live**: a `supermux-skills-N` wrapper added to / removed from `<ctx>/plugins`, then `reload_plugins` (`reload_skills` does not load a new plugin) | **live**: a symlink added to / removed from `<ctx>/plugins`, then `reload_plugins` (`hold_on_cache_impact` with `holdOnCacheImpact`) | **live**: `mcp_set_servers` with the full dynamic set; removing a server that came with the launch (`--mcp-config`) is a **reload** |
400
+ | Codex | **live**: `skills/extraRoots/set` with the full new list | **live** when the plugin maps to skills only; with `.mcp.json` servers: **reload** | **reload** (new app-server with the new `-c` args + `thread/resume`) |
401
+ | Grok | reload | reload | reload |
402
+ | OpenCode | reload | reload | reload |
403
+ | Cursor | unsupported | unsupported | **reload** (new process + `session/load`) |
404
+ | Antigravity | reload | reload | reload |
405
+ | ACP (generic) | unsupported | unsupported | reload (unverified) |
406
+
407
+ Antigravity reads its plugins and `mcp_config.json` when it starts (it has no MCP flag and no live reload), so each of its changes relaunches agy with `--conversation <id>` between turns. Live mechanisms are confirmed per process: a Claude process launched without the plugin folder (a session that had no context) reloads instead; a re-attached Claude process recorded which servers it got at launch in keeper meta. Live check: `bun scripts/context-live-update.ts [claude] [codex] [cursor] [grok] [opencode]` (scratch in `~/.cache/context-c1b/`).
408
+
409
+ ## Host MCP servers
410
+
411
+ MCP servers whose tools are TypeScript functions in the host process. The core ships **no default server**; a host builds as many as it wants and attaches them like any MCP server. All MCP logic runs on the official SDK `@modelcontextprotocol/server` 2.3.0 (zod 4): one SDK `McpServer` per (session, server) connection.
412
+
413
+ ```ts
414
+ import { mcpServer, tool } from "@supermux/core/mcp"
415
+ import { z } from "zod" // zod 4 (the SDK's peer)
416
+
417
+ const orders = mcpServer({
418
+ name: "orders", // ^[A-Za-z0-9_-]+$, unique per session
419
+ instructions: "Order lookup", // optional, MCP server instructions
420
+ toolChanges: "reload", // default; or "live-only" (see below)
421
+ tools: {
422
+ lookup: tool({
423
+ description: "Look up an order",
424
+ input: z.object({ id: z.string() }), // validated before run; omitted: no arguments
425
+ output: z.object({ total: z.number() }), // optional: the result also goes out as structuredContent
426
+ async run({ id }, ctx) { return db.orders.get(id) },
427
+ }),
428
+ },
429
+ })
430
+ // or any SDK server, built once per (session, connection): an SDK server serves one connection
431
+ const custom = mcpServer({ name: "custom", create: ctx => buildMyMcpServer(ctx) })
432
+ // a create server without zod: "@supermux/core/mcp" re-exports the SDK's McpServer and fromJsonSchema
433
+ // sdk.registerTool("say", { description, inputSchema: fromJsonSchema({ type: "object", … }) }, handler)
434
+
435
+ createCore({ …, mcpServers: [orders], context: { mcpServers: [orders] } }) // registry + a default for every session
436
+ core.sessions.create({ …, context: { mcpServers: [custom, { name: "github", command: "gh-mcp" }] } })
437
+ orders.add("cancel", tool({ … })) // live in every attached session (below)
438
+ orders.remove("lookup")
439
+ core.mcp.register(server) / core.mcp.unregister(name) / core.mcp.get(name) / core.mcp.list() / core.mcp.socket()
440
+ core.capabilities(agent).hostToolChanges // "live" | "reload"
441
+ ```
442
+
443
+ - **`ctx`** = `{ sessionId, agent, account?, server, signal }`. `sessionId` is the core session whose agent (or one of its subagents: they share the agent's MCP connections) made the call. For a tool, `signal` aborts when the agent cancels the call (`notifications/cancelled`) or the session is interrupted (`session.interrupt`); for `create(ctx)` it aborts when that connection closes.
444
+ - **Results:** a `string` → one text block; an MCP `CallToolResult` (`{ content: [...], isError? }`) as is; any other value → its JSON as text (plus `structuredContent` when the tool has `output`). A throw → `{ isError: true, content: [{ type: "text", text: message }] }`. Invalid arguments → an `isError` result "Input validation error: …" (the SDK's).
445
+ - **Registry and records.** `SessionContext.mcpServers` takes host server objects on create, resume, fork and `updateContext` (`mcpServers: { add: [server], remove: ["name"] }`), exactly like external ones. An object in a context is registered automatically; a second, different object with a registered name → `invalid_context` (`core.mcp.register` → `invalid_input`, `createCore({ mcpServers })` → `invalid_options`). The record stores `{ kind: "host", name }` only. Every launch (create, resume, reload, keeper relaunch) resolves the names through the registry; an unregistered one → `missing_mcp_servers` and nothing launches. After a host restart, pass the servers again (`createCore({ mcpServers })` or `core.mcp.register`) before resuming. `unregister` closes the server's open connections (their bridges answer "host unavailable" and keep retrying).
446
+ - **How it reaches the agent.** Each attached host server is one ordinary stdio MCP server under its own name (`mcp__orders__lookup` in Claude), using the C1 channels (Claude `--mcp-config`, Codex `-c mcp_servers.*` with the per-server approve, ACP `mcpServers`, Antigravity the shadow HOME's `mcp_config.json`). Its command is the bridge: `process.execPath` + [`dist/mcp/bridge.js` (or `src/mcp/bridge.ts` when the host runs the sources under Bun), `--server`, name], env `SUPERMUX_MCP_SOCKET`, `SUPERMUX_MCP_SESSION`, `SUPERMUX_MCP_TOKEN`. A host on Node gets `node …/dist/mcp/bridge.js`; on Bun, `bun …/bridge.(js|ts)`. The bridge has no dependencies and no MCP code: it pipes newline-delimited JSON-RPC between stdio and the socket after a one-line hello `{ protocol: "supermux-mcp-bridge/1", token, sessionId, server }`.
447
+ - **Socket.** One Unix socket per core, `<stateDirectory>/mcp/mcp.sock` (0600, directory 0700), opened at the first launch that has a host server. When that path exceeds sun_path (107 bytes on Linux, 103 on macOS) it is, as for a keeper, a short path in a private per-user 0700 directory, `<base>/supermux-mcp-<uid>/<sha256(stateDirectory)[:16]>.sock`, under the OS temp dir (else `/tmp`, else `$XDG_RUNTIME_DIR`, last because it is deleted at logout without lingering): the first whose directory this user can make private (its own, not a link; `invalid_options` when none can be, or every path is too long). It depends only on the state directory and the environment, so a restarted host listens where the bridges of detached sessions reconnect. `socketPaths(stateDirectory)` (from `@supermux/core/mcp`) lists those candidates, best first, and creates or changes nothing: the host listens on the first whose directory it can make private, which is the first unless that one is unusable (`invalid_options` when every one is too long), and `core.mcp.socket()` says which is in use. A stale socket file is removed on start; a live one (another core) → `mcp_socket_in_use`.
448
+ - **Token.** `HMAC-SHA256(secret, sessionId + "\0" + server)`, the secret generated once into `<stateDirectory>/mcp/secret` (0600). It is the same on every launch and after a host restart, so a detached session's bridge still authenticates. The hello is refused for a bad token (the bridge exits), an unknown server or session, or a server the session does not have (those keep retrying). It never enters the record or the keeper fingerprint.
449
+ - **Detached sessions.** The keeper fingerprint holds a host server's **name** only (plus its sorted tool names on an agent that ignores `list_changed`, see below), never the token, socket or runtime path, so a restart with the same servers re-attaches the running agent. When the host goes away the bridge keeps running and reconnects with backoff (100 ms doubling to 5 s; `SUPERMUX_MCP_RETRY_MAX_MS`). While it is disconnected every JSON-RPC **request** from the agent gets `{ code: -32000, message: "host unavailable" }` at once; notifications are dropped; calls are never queued. On reconnect the bridge replays the agent's original `initialize` (its answer is swallowed) and `notifications/initialized` before anything else, so the new host-side server is initialized.
450
+ - **Tool changes.** `server.add(name, tool)` / `server.remove(name)` (tools servers only; a `create` server changes its own SDK servers) register / remove the tool on every live connection; the SDK sends `notifications/tools/list_changed`. Claude and OpenCode re-list (`hostToolChanges: "live"`): `context.updated` with `{ kind: "tools", op, item: "<server>/<tool>", how: "live" }`. Codex, Grok, Cursor and Antigravity do not (`"reload"`): the core relaunches each affected open session at idle (the updateContext rules: never mid-turn, queue held, one relaunch for all changes made meanwhile) and emits `how: "reload"`. With `toolChanges: "live-only"` there is no relaunch (`how: "unsupported"`; the agent sees the change at its next launch).
451
+ - **Events** on the session stream: `{ type: "tool.called", sessionId, server, tool, callId }` and `{ type: "tool.finished", sessionId, server, tool, callId, ok, durationMs }` (`ok` false for an error, an `isError` result, a cancel or a lost connection). Never arguments or results: they may be secret; log them in `run()` if needed. The agent's own MCP tool events (`mcp-tool` / `tool-call` bodies) show the same calls.
452
+ - **Protocol notes.** The SDK negotiates 2025-06-18 (Codex) and 2025-11-25 (the others). Claude sends `server/discover` first; the SDK answers -32601 and Claude falls back to `initialize` (not special-cased).
453
+
454
+ Live check: `bun scripts/context-live-mcp.ts [claude] [codex] [cursor] [grok] [opencode]` (scratch in `~/.cache/context-c2/`): per agent two host servers plus one external server, a mid-session tool add, a throwing tool, a subagent's call, and a detached restart (same agent pid, a call through the reconnected bridge). Results in `docs/core-design/session-context.md` "C2 as built".
455
+
456
+ ## Environment
457
+
458
+ `@supermux/core/environment` owns **mechanism**: session-private home, config.toml, credential copy/canonical path. The caller owns **content** (MCP server command/args/env, skill paths). It writes no instruction files: agent instructions travel only through the session context (`createCore({ context })` / `sessions.create({ context })`, see "Session context"). No defaults: every field on `GrokEnvironmentSpec` / `CodexEnvironmentSpec` / `OpenCodeEnvironmentSpec` / `CursorEnvironmentSpec` / `ClaudeEnvironmentSpec` / `AntigravityEnvironmentSpec` is required (`requireSpec` throws `TypeError('<field> is required')` for a missing field, including explicit-null fields `provider`, `sharedRuntime`, and `credentials.apiKey`). `sessionId` / `sessionName` are not environment-spec fields — the broker puts them into the mux-shim server env itself. `skillsPaths: []` omits the grok `[skills]` table / OpenCode `skills` object. MCP `name` must match `/^[A-Za-z0-9_-]+$/`.
459
+
460
+ ```ts
461
+ prepareGrokEnvironment(spec: GrokEnvironmentSpec): Promise<PreparedEnvironment>
462
+ prepareCodexEnvironment(spec: CodexEnvironmentSpec): Promise<PreparedEnvironment>
463
+ prepareOpenCodeEnvironment(spec: OpenCodeEnvironmentSpec): Promise<PreparedEnvironment>
464
+ prepareCursorEnvironment(spec: CursorEnvironmentSpec): Promise<PreparedEnvironment>
465
+ prepareClaudeEnvironment(spec: ClaudeEnvironmentSpec): Promise<PreparedEnvironment & { args: string[] }>
466
+ prepareAntigravityEnvironment(spec: AntigravityEnvironmentSpec): Promise<PreparedEnvironment>
467
+ ```
468
+
469
+ Grok writes `<home>/.grok/config.toml`, points `GROK_AUTH_PATH` at the canonical auth file (legacy private copy is promoted when newer). Codex writes `<home>/config.toml` (0600), sets `CODEX_HOME`, copies `auth.json` unless `apiKey` is set (then `OPENAI_API_KEY`). OpenCode writes `<configHome>/opencode/opencode.json` (0600), sets `XDG_CONFIG_HOME` only; credentials stay under the user's XDG_DATA_HOME (`credentials: "none"`). `provider: null` and `pluginPaths: []` omit those keys. Cursor writes `<home>/.cursor/mcp.json` (0600), copies `cli-config.json` / `agent-cli-state.json` and `auth.json` unless `apiKey` is set (then `CURSOR_API_KEY`), and links `.local/share/cursor-agent` to `sharedRuntime.source` when that field is non-null. Claude does **not** isolate HOME (auth lives in the user's `~/.claude`). `prepareClaudeEnvironment` writes session-private `<home>/mcp.json` (0600) and returns CLI `args`: `--append-system-prompt-file` for each of `systemPromptFiles`, then `--plugin-dir` / `--add-dir`, then `--strict-mcp-config` (when `strictMcp`) and `--mcp-config`. `nativeMemory: false` sets `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`. `credentials: "none"`. `PreparedEnvironment.args` is Claude-only.
470
+
471
+ Antigravity: `AntigravityEnvironmentSpec` is `{ home, userHome, platform }` (`home` is the shadow HOME to build, `userHome` the user's real HOME, which the library only reads). agy finds its config only through `$HOME`, and its shell tool inherits it, so each session runs in a shadow HOME: every top-level entry of the real HOME is symlinked in, except `.gemini`, which is a real directory. In it, `antigravity-cli` links to the user's agy cli dir (the driver points it at an account's own cli dir when the session runs on an account), `config/` is real, with a generated `mcp_config.json` (the user's own config; the driver adds the session's servers at each launch) and a `plugins/` that links the user's plugins and holds the generated `supermux-*` ones, and the other entries link to the user's. `prepareAntigravityEnvironment` returns `env: { HOME: <shadow> }` (plus `USERPROFILE` on Windows), `files: [<shadow>/.gemini/config/mcp_config.json]` and `credentials: "canonical"` when `<userHome>/.gemini/antigravity-cli/antigravity-oauth-token` exists, else `"none"`. The library itself never changes anything under the real HOME (a shadow HOME that is a symlink, the real HOME, an ancestor of it or inside the real `~/.gemini` is refused with a `TypeError`), but agy writes its conversations into the real `~/.gemini/antigravity-cli` through the shadow's `antigravity-cli` link. The spec carries no MCP or skill content: the driver writes those from the session context at every launch (see Session context).
472
+
473
+ **Account mode** (`credentials.account: true`, Codex and Cursor): the session's credential comes from an account at launch, so no `auth.json` is copied; a copy left by an earlier launch is healed back to the canonical file (`promoteIfNewer`) and removed, and `credentials` is `"account"`. Cursor still copies `cli-config.json` / `agent-cli-state.json`. `releaseSessionCredential({ sessionCopy, canonical, freshness })` (heal, then remove; a copy whose promotion failed is kept) and `refreshSessionCredential(...)` (heal, then copy the canonical file again) do the same for a host that switches accounts between launches.
474
+
475
+ Credential helpers (`promoteCredential`, `promoteIfNewer`, `jwtExpiryMs`, `readCredentialJson`, `cursorCredentialFreshness`) are exported for copy-transport agents.
476
+
477
+ ## Accounts
478
+
479
+ `@supermux/core/accounts` (root re-exports `fileVault`, `memoryVault` and the account types). Several logins per agent, chosen per session. **Credentials travel by environment; homes hold history.**
480
+
481
+ `CoreOptions.accounts?`: `{ registry?, usage?, vault?, homes?: { claudeRoot?, codexRoot?, grokRoot?, cursorRoot?, antigravityRoot? }, autoSwitch?, continueAfterSwitch?, continuePrompt?, fetch?, login?: { runner?, commands?, timeoutMs? } }`. `vault` defaults to `fileVault(<stateDirectory>/accounts/vault)` (dir 0700, one 0600 file per secret, atomic write+rename, ids `^[a-zA-Z0-9_-]{1,128}$`, no encryption). `memoryVault()` is for tests. History roots default to `$CLAUDE_CONFIG_DIR` or `~/.claude`, and `$CODEX_HOME` or `~/.codex`. `grokRoot` (`$GROK_HOME` or `~/.grok`), `cursorRoot` (`$XDG_CONFIG_HOME` or `~/.config`) and `antigravityRoot` (`~/.gemini/antigravity-cli`) only locate the system logins for their identity. `autoSwitch` defaults to off, `continueAfterSwitch` to on. `fetch` (default `globalThis.fetch`) is the token-refresh transport. `autoSwitch` may be a function, read each time a limit is seen (a host toggles it at runtime; a throwing function counts as off). **Shared registry:** a process running one Core per agent passes the same `registry` (`new AccountRegistry(dir, agents, { vault?, homes?, fetch? })`, metadata under `<dir>/accounts`) and `usage` (`new UsageStore(<registry.directory>/usage.json)`) to each, so there is one source of truth and one writer per file. The registry must know every agent of the Core (`invalid_options`); its own `vault`/`homes`/`fetch` apply. `core.accounts.list()` without an agent then lists every agent's accounts; `list(agent)`, `pick`, `login` and sessions still accept only the Core's own agents.
482
+
483
+ `Account`: `{ id, agent, method: "system" | "api_key" | "token" | "subscription", label?, identity?: { email?, org?, accountId? }, isolated?, provider?, createdAt }`. Metadata lives in `<stateDirectory>/accounts/accounts.json` (atomic, 0600). Secrets live only in the vault. `SessionRecord.account` holds the id only.
484
+
485
+ `core.accounts`:
486
+ - `list(agent?)`, `get(id)`. One built-in **system** account per configured agent (`"<agent>:system"`): the CLI's own login in the real home. supermux never moves, copies or overrides it, and the CLI refreshes it in place. Its identity is read lazily (Claude `oauthAccount` from `.claude.json`, which is `~/.claude.json` for the real `~/.claude`; Codex `auth.json` `tokens.account_id` plus the `id_token` email; Grok `auth.json` entry `email`/`user_id`/`team_id`; Cursor `cursor/auth.json` access-token JWT `sub`; Antigravity `antigravity-oauth-token` `id_token` JWT `email` and `sub`).
487
+ - `add({ id?, agent, method, secret?, label?, isolated?, identity?, provider? })`. `api_key`/`token` need `secret`. `subscription` refuses one (its login stays in the account home). `id` defaults to `<agent>-<8 hex>`. Methods per agent: claude all, codex all, cursor/grok/antigravity `api_key` + `subscription`, opencode `api_key` (needs `provider`). An agent id with no adapter has only its system account. A **rotating** login (system, subscription, or a Codex token whose secret has a `refresh_token`) whose identity matches another rotating login of that agent → `account_exists`, because refresh tokens are single-use and two copies log each other out. Non-rotating tokens/keys may share an identity.
488
+ - `remove(id, { deleteHome? })`: removes the metadata, secret and persisted usage. A subscription home stays on disk because it holds that login, unless `deleteHome: true`: its top-level symlinks (the shared entries) are unlinked first, never followed, then the private files are removed. The history root is never touched. System accounts cannot be removed (`invalid_input`).
489
+ - `login(options)` → `LoginHandle` (see Guided login).
490
+ - `system(agent)`, `home(id)`: `home` creates and returns a subscription account's home, so the host can run the CLI's login in it (e.g. `CLAUDE_CONFIG_DIR=<home> claude auth login`, `CODEX_HOME=<home> codex login`).
491
+ - `pick(agent, exclude?)`, `usage(id)` (see policy).
492
+
493
+ What each method does at launch (adapter `materialize` → `profile.env` / `profile.unsetEnv` / `profile.args`):
494
+
495
+ | Agent | api_key | token | subscription |
496
+ | --- | --- | --- | --- |
497
+ | claude | `ANTHROPIC_API_KEY`, unset `CLAUDE_CODE_OAUTH_TOKEN` | `CLAUDE_CODE_OAUTH_TOKEN` (setup-token), unset `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` | `CLAUDE_CONFIG_DIR=<home>`, unset all three |
498
+ | codex | `CODEX_API_KEY` + `OPENAI_API_KEY` | vault JSON `{access_token, account_id, refresh_token?, expires_at?}` → `SUPERMUX_CODEX_ACCESS_TOKEN` + `-c model_provider=supermux_chatgpt …` (ChatGPT backend, `chatgpt-account-id` header). With a `refresh_token`, the vault refreshes it (see Token refresh); without one, expired → `account_expired` | `CODEX_HOME=<home>` |
499
+ | cursor | `CURSOR_API_KEY` | n/a | `XDG_CONFIG_HOME=<home>/xdg` |
500
+ | grok | `XAI_API_KEY` | n/a | `GROK_AUTH_PATH=<home>/auth.json` |
501
+ | opencode | `OPENCODE_AUTH_CONTENT`: the v2 `account.json` shape **plus** a legacy `{ <provider>: { type: "api", key } }` entry. opencode 1.16.2 reads both: v2 alone leaves the provider without a credential, and legacy alone is migrated into a written `account.json` | n/a | n/a |
502
+ | antigravity | `GEMINI_API_KEY`, plus `SUPERMUX_ANTIGRAVITY_CLI_DIR=<home>` (an empty private agy cli dir, so agy cannot fall back to the user's own token) | n/a | `SUPERMUX_ANTIGRAVITY_CLI_DIR=<home>`, unset `GEMINI_API_KEY` |
503
+
504
+ **Account homes** (`<stateDirectory>/accounts/homes/<agent>/<id>/`, 0700). Shared entries are symlinked to the history root (a missing root entry is created first: a dir, an empty file, or `{}` for `settings.json`). Claude shares `projects/ skills/ commands/ agents/ plugins/ history.jsonl settings.json CLAUDE.md`. `.credentials.json`, `.claude.json`, `sessions/` and `ide/` stay private. Codex shares `sessions/ archived_sessions/ thread-writer-locks/ session_index.jsonl history.jsonl`. `auth.json`, `models_cache.json` and the sqlite state stay private (verified live: app-server `thread/resume` works across homes without sharing sqlite). Antigravity has no layout here: its home is the account's agy cli dir (`antigravity-oauth-token`; an `api_key` account gets an empty one), which the driver links into the shadow HOME as `~/.gemini/antigravity-cli`, and at each launch it links the real cli dir's `conversations/` and `brain/` into it (when they exist, and only where the account's entry is missing or an empty directory), so history stays shared. An existing non-link entry is replaced only when empty, otherwise `account_home_conflict`. `isolated` accounts get no links and are never switched to. Windows: directory junctions for dirs; shared files are `unsupported_operation`, so use isolated accounts there.
505
+
506
+ **Sessions.** `create({ …, account })`: `account` and `authProfile` are mutually exclusive (`invalid_input`). With neither, the session runs on the system account exactly as before: no profile, and no `account` in the record. `resume(id, { account })` switches the account. The account must belong to the session's agent (`invalid_input`; unknown → `unknown_account`). A session with an `authProfile` cannot switch (`invalid_input`). A live session is shut down (`close({ mode: "shutdown" })`, which cancels an in-flight turn and queued input) and reopened with the **same native id** under the new account. The record is updated and `account.switched { sessionId, from, to, reason: "manual" }` is emitted. Same account → no reopen. **Detached keeper sessions:** a resume re-attaches to the parked process, which keeps its old environment. To switch, first reopen it and `close({ mode: "shutdown" })`, then resume with the account. `DriverContext.account` carries the id. `AuthProfile.unsetEnv` keys are removed after the env merge, and `AuthProfile.args` are appended to the CLI args (Codex: before the multi_agent v1 catalog step, which keeps `app-server` first). All four driver families (claude, codex, acp/grok/cursor/opencode, antigravity) honour both.
507
+
508
+ **Usage and policy** (`accounts/policy.ts`). `usage` bodies' `rateLimits` are normalised per adapter into `UsageWindow { name, usedPercent, resetsAt? }`. Claude: `rate_limit_info.unifiedWindows[*].utilization` ×100, and `status: "rejected"` marks `rateLimitType` full. Codex: `primary`/`secondary.usedPercent`, named `<windowDurationMins>m`, and `rateLimitReachedType` marks a full window. Antigravity reports no windows (its `usage` bodies carry token counts only and its adapter has no usage parser), so `pick` has no data for its accounts and no limit switch is queued for it. Unknown shapes are skipped. `pick` (Ghostex "Auto"): per account, the min over windows of `(100 − used) / max(hoursUntilReset, 1/60)`. A window without `resetsAt` counts as 1 h, and a window past its reset counts as unused. Highest wins. Limited accounts are skipped. Accounts without data rank after those with data, in list order (system first). Isolated and Cursor accounts are never picked. With `autoSwitch`, a window ≥ 100% on a session queues a switch. When the current turn has ended (state idle/failed, never mid-turn), Core resumes the session on the picked account and emits `account.switched` with `reason: "limit"`, or `account.exhausted { sessionId, agent, account }` when nothing is available. Input queued behind the limited turn runs first and may fail. Sessions on isolated or Cursor accounts are not auto-switched. After a limit switch, if the turn before it ended because of the limit (its `message.completed` was `failed`, or its stop reason names a limit or quota, or a window ≥ 100% was seen during it), Core sends **one** continuation prompt on the new account: `continuePrompt` (default `"Continue from where you stopped. Your previous turn hit a usage limit and you are now on another account."`). It is skipped when the host had input queued at switch time or the new session is busy, and never sent after a manual switch. `continueAfterSwitch: false` turns it off.
509
+
510
+ **Unavailable accounts.** A driver that sees the agent's API refuse the account itself sets `errorType: "account_unavailable"` (`ACCOUNT_UNAVAILABLE_ERROR`) on its `error` body. Claude does so for a `result` with `api_error_status` 401 or 403, e.g. `oauth_not_allowed_for_organization` after a cancelled subscription. Antigravity does so for a failed turn whose error message names an eligibility check, a missing sign-in, `unauthenticated`, `PERMISSION_DENIED` or HTTP 401 or 403. Core then marks the account unavailable (`core.accounts.unavailable(id)` → `{ reason, since }`, persisted in `usage.json`) and emits `account.unavailable { sessionId, agent, account, reason }` the first time. With `autoSwitch`, the session switches like on a limit (after the turn, `account.switched` with `reason: "unavailable"`, or `account.exhausted` with `reason: "unavailable"`), and the refused turn continues once with its own default prompt (a host's `continuePrompt` serves both). `pick` and the auto-switch skip unavailable accounts. The mark never blocks a manual choice: the next turn that completes on the account clears it (`account.available`), and so does `remove`. Nothing probes an account in the background.
511
+
512
+ Usage is persisted to `<stateDirectory>/accounts/usage.json` (0600, atomic, writes debounced by 1 s and flushed on `core.close`), so `pick` has data after a restart. Only windows with a `resetsAt` in the future are kept; a window without one could never be known to have reset. An unreadable file is ignored (a cache).
513
+
514
+ **Guided login.** `core.accounts.login({ agent, id?, label?, email?, isolated?, as?: "subscription" | "token" })` returns a `LoginHandle` synchronously: `state()` → `{ phase: "starting" | "awaiting_user" | "verifying" | "done" | "failed" | "cancelled", url?, code?, needsCode?, deadline?, error?, errorCode? }`, `on(listener)` (returns an unsubscribe), `submitCode(text)`, `cancel()`, `done: Promise<Account>`. `needsCode` is set when the CLI waits for a code passed to `submitCode` (Claude, Antigravity); `deadline` (epoch ms) is set while awaiting the user when the CLI has its own limit (Antigravity: 60 s after the URL appears). Invalid options throw at once (`unknown_agent`, `invalid_input`, `invalid_account_id`; OpenCode and agents without an adapter → `unsupported_operation`; `as: "token"` is Codex only; `email` is Claude only). The agent's own login runs in a throwaway `<stateDirectory>/accounts/pending/<uuid>/` (0700) as its working directory, in its own process group, with `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `OPENAI_API_KEY`, `CODEX_API_KEY`, `CURSOR_API_KEY`, `XAI_API_KEY` and `GEMINI_API_KEY` removed from its env:
515
+
516
+ | Agent | Isolation | Command | User step |
517
+ | --- | --- | --- | --- |
518
+ | claude | `CLAUDE_CONFIG_DIR=<tmp>` | `claude auth login --claudeai [--email E]` in a PTY (`script`; util-linux and BSD forms) | open `url`, then `submitCode(code)`: the code and Enter are typed as two writes |
519
+ | codex | `CODEX_HOME=<tmp>` | `codex -c cli_auth_credentials_store="file" login --device-auth` | open `url`, enter `code` |
520
+ | grok | `GROK_AUTH_PATH=<tmp>/auth.json`, `GROK_HOME=HOME=<tmp>` | `grok login --device-auth` | open `url`, enter `code` |
521
+ | cursor | `XDG_CONFIG_HOME=<tmp>/xdg`, `NO_OPEN_BROWSER=1` | `cursor-agent login` (or `agent`) | open `url` |
522
+ | antigravity | `HOME=<tmp>/.login-home`, whose `.gemini/antigravity-cli` links to `<tmp>` (the token lands in the login dir and the real `~/.gemini` is never written); a no-op `xdg-open` / `open` first on `PATH` | `agy -p="Reply with the single word OK."` in a PTY (`script`) | open `url` (Google sign-in), then `submitCode(code)` before `state().deadline` |
523
+
524
+ Success means exit 0 (Antigravity also accepts a nonzero exit once its credential file exists: an account that still needs Google's browser eligibility check exits 1 after saving its token), the credential file exists (`.credentials.json`, `auth.json`, `auth.json`, `xdg/cursor/auth.json`, `antigravity-oauth-token`) and the identity is readable (Claude `.claude.json` `oauthAccount`; with `email`, a different signed-in email → `account_identity_mismatch`; Antigravity the token's `id_token` JWT). Then the rotating-login duplicate rule applies: the same identity as the system login or another account → `account_exists` naming it, and the temp dir is deleted. `as: "subscription"` (default) renames the temp dir into the account home (same filesystem) and creates the shared links (Antigravity has none: its driver shares history at launch). Anything the login run left under a shared name (e.g. a default `settings.json`) is dropped first, since it is a CLI default and not user data. For Antigravity, its `.login-home` and what its one turn left behind (`conversations`, `brain`, `annotations`, `implicit`, `conversation_summaries.db*`) are removed after verification instead, never through symlinks. An existing home dir of that id → `account_home_conflict`. `as: "token"` (Codex) moves `auth.json` `tokens` into the vault as `{ access_token, refresh_token, id_token, account_id, expires_at }` (`expires_at` from the access-token JWT `exp`) and deletes the temp dir, so the vault is the only holder of that login. The whole login times out after `login.timeoutMs` (default 30 min) → `login_timeout`. `cancel()` → `login_cancelled` (no effect once `verifying`). Other failures → `login_failed` with the exit code and the end of the CLI output. Temp dirs are always removed on failure, and `core.close` cancels running logins. `login.runner` replaces process spawning (`LoginSpawn { command, args, env, cwd, pty }` → `LoginProcess { onOutput, onExit, write, kill }`); `login.commands` overrides the CLI per agent kind. Windows has no `script`, so the Claude and Antigravity logins are `unsupported_operation` there. On macOS, Claude and Cursor may keep the login in the Keychain instead of the file this checks. Live: `bun scripts/accounts-login-live.ts <agent> [--as token] [--timeout-min N] [--cancel-at-url]`.
525
+
526
+ **Token refresh (vault-owned, Codex `token` accounts with a `refresh_token`).** `materialize` refreshes when the access token expires within 5 minutes (JWT `exp`, or `expires_at`). Session opens ask for 30 minutes, so a new session is never due right away. The refresh is a `refresh_token` grant to `https://auth.openai.com/oauth/token` with the Codex CLI client id. It runs once per account at a time: concurrent callers share one in-process refresh, and processes share `<stateDirectory>/accounts/locks/<id>.lock` (O_EXCL; stale when its pid is gone or after 2 minutes; waits up to 30 s). After taking the lock it re-reads the vault, in case another process already refreshed. The rotated secret is written to the vault **before** the new access token is used. The old refresh token is kept when the reply omits one. Failures: `refresh_token_expired` / `refresh_token_reused` / `refresh_token_invalidated` / `invalid_grant` → `account_expired`, anything else → `account_refresh_failed`. The message carries the server's error code, never a token. A live token session holds its token in the agent process, so Core schedules a reopen 30 minutes before expiry. When the session is idle with nothing queued (never mid-turn), it is resumed on the same account and native id with a fresh token, and `account.refreshed { sessionId, account }` is emitted (not `account.switched`). Never use this with a refresh token copied from `~/.codex/auth.json`: it is single-use, and refreshing it logs out the system login.
527
+
528
+ ## Auth helper (deprecated)
529
+
530
+ **Deprecated:** per-session credential copies race on single-use refresh tokens. Use accounts (env injection, or one shared account home per login). The code stays for existing callers.
531
+
532
+ `copiedCredentials({ source, homesDirectory, filename, homeVariable })` + `withAuth(driver, provider)`. Fork disabled. Failed open + failed lease release: `provider.close()`. Native expiry stays on the driver; Core does not inspect tokens. This is **opt-in**; examples that talk to real CLIs should use caller env/home instead of silent copies.
533
+
534
+ ## Custom drivers
535
+
536
+ Implement `AgentDriver.open(DriverContext): AgentRuntime`. Honor `signal`, `onExit`, serialize protocol internally, settle `close` after releasing resources. Core owns records and the send queue.
537
+
538
+ A driver whose agents can outlive the process (detached) may implement `shutdownDetached(sessionId): Promise<void>`: stop what still runs for that session without launching anything, resolving once it is gone and at once when nothing runs. `sessions.close(id, { mode: "shutdown" })` calls it after closing any open Session. Likewise `forgetDetached(sessionId): Promise<boolean>` deletes what the driver keeps on disk for the session, or resolves `false`, deleting nothing, while its agent still runs; `sessions.forget(id)` calls it and is then `session_busy`. A driver that runs its agent through `connectKeeper` gets both from `detachedKeeperHooks(keeperStateDirectory, limits)` (root export; `limits` is a `StopKeeperLimits`, `{ shutdownTimeoutMs, connectTimeoutMs }`).
539
+
540
+ If you advertise `configure`, `configure(requested)` must apply the **full** requested object (missing keys are factory defaults). If you implement `configuration()`, return a **copy** of applied state — Core may clone it, and callers must not share a mutable open-time snapshot.
541
+
542
+ Core always passes `onActivity`. Call it only if the native runtime has autonomous work **outside** an owned `prompt`: `context.onActivity?.({ id, phase })`. Core clones notices; mutating the object after return has no effect. Omit calls to keep owned-prompt lifecycle only. Overflow (one more distinct outstanding id than `limits.outstandingActivity`, 256 under `supermux()`'s default) fails open/session with `activity_overflow`.
543
+
544
+ ## Examples
545
+
546
+ Packed tarball includes the single-file examples (`examples/*.mjs`) and this file; an app with a folder of its own (`examples/chat`) is in the repository, to be copied out. Run from a project that depends on the installed package, or from the repository after build. Do not use `NODE_PATH` for ESM. `examples/real-agents.mjs` does **not** copy credentials via `copiedCredentials`; vendor CLIs may still refresh credentials or write history under HOME. Default live prompt: `Reply with the single word pong. Do not use tools.` Do not treat these snippets as live-model tests.
547
+
548
+ Pre-open config (configure-capable driver; `{}` is omitted). Codex/Grok both advertise `configure`; do not execute against a live CLI from CI:
549
+
550
+ ```js
551
+ const session = await core.sessions.create({
552
+ id: "codex-tuned", // required: yours, unique in the state directory
553
+ agent: "codex", // or "grok"
554
+ cwd,
555
+ configuration: { model: "gpt-5", reasoningEffort: "low" },
556
+ })
557
+ await core.sessions.create({ id: "codex-defaults", agent: "codex", cwd, configuration: {} }) // same as omitted
558
+ ```
559
+
560
+ Failed-start leftover: nonempty create config on a runtime **without** `configure` throws `unsupported_operation` **after** `driver.open`. If leftover `close` fails, the create/resume rejects with an **`AggregateError`** (no `code`; `errors[0]` is the open failure, the last entry is the cleanup failure), and create/resume of that id is `session_busy` until `sessions.close(id, { mode: "shutdown" })` (or `core.close({ agents: "shutdown" })`) confirms cleanup. See `examples/custom-driver.mjs` (no network; CI Node 22 smoke).
561
+
562
+ Subscribe owned-prompt order (started is not native activity):
563
+
564
+ ```js
565
+ core.subscribe(event => {
566
+ if (event.type === "message.accepted" || event.type === "message.started" || event.type === "message.completed") {
567
+ console.log(event.type, event.messageId)
568
+ }
569
+ })
570
+ ```