@runuai/host 0.9.13 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * GrokSession — a real AgentSession backed by xAI's **Grok** CLI, run inside
3
- * the task container:
3
+ * the task environment:
4
4
  *
5
- * docker exec -i task-<id>-app-1 ~/.grok/bin/grok -p "<prompt>" \
5
+ * ~/.grok/bin/grok -p "<prompt>" \
6
6
  * --output-format streaming-json --permission-mode bypassPermissions \
7
7
  * -m grok-4.5 --system-prompt-override "<briefing>" [-r <sessionId>]
8
8
  *
9
9
  * Like Kimi (and unlike Claude's persistent stdin / Codex's app-server), Grok
10
10
  * headless mode is **one-shot per turn**: `-p` runs one prompt to completion,
11
11
  * streams newline-delimited JSON, and exits. Each `send()` spawns a fresh
12
- * `docker exec`; sends are **serialized** so a second message can't race a
12
+ * provider process; sends are **serialized** so a second message can't race a
13
13
  * running turn. Continuity is `-r <sessionId>` — captured from the turn-end
14
14
  * event (per-agent isolation, since each session tracks its own id).
15
15
  *
@@ -28,11 +28,20 @@
28
28
  * it in-container. Cloud never sees it (ADR-015). `--system-prompt-override`
29
29
  * carries the channel briefing (no fold-into-turn-1 needed).
30
30
  */
31
- import { spawn, type ChildProcess } from "node:child_process";
32
31
  import { existsSync } from "node:fs";
33
32
  import { homedir } from "node:os";
34
33
  import { join } from "node:path";
35
34
 
35
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
36
+ import type {
37
+ TaskEnvironmentAgentSessionSurface,
38
+ TaskEnvironmentSessionRequest,
39
+ } from "../task-environment/types";
40
+ import {
41
+ AGENT_SESSION_OUTPUT_BUFFER_BYTES,
42
+ spawnEnvironmentProcess,
43
+ type AgentEnvironmentProcess,
44
+ } from "./proc";
36
45
  import { register } from "./registry";
37
46
  import type {
38
47
  AgentEvent,
@@ -94,15 +103,15 @@ export function mapGrokLine(line: string): MappedGrokLine {
94
103
  // Session
95
104
  // ---------------------------------------------------------------------------
96
105
 
97
- export type Spawner = (args: string[]) => ChildProcess;
98
- const defaultSpawn: Spawner = (args) =>
99
- spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
106
+ export type Spawner = (
107
+ request: TaskEnvironmentSessionRequest,
108
+ ) => AgentEnvironmentProcess;
100
109
 
101
110
  export class GrokSession implements AgentSession {
102
111
  readonly agentId: string;
103
112
  readonly kind: AgentKind = "grok";
104
113
 
105
- private readonly containerName: string;
114
+ private readonly environment: TaskEnvironmentAgentSessionSurface;
106
115
  private readonly model?: string;
107
116
  private readonly systemPreamble: string;
108
117
  private readonly agentEnv: Record<string, string>;
@@ -110,24 +119,25 @@ export class GrokSession implements AgentSession {
110
119
 
111
120
  private readonly handlers = new Set<AgentEventHandler>();
112
121
  private sessionId: string | null = null;
113
- private current: ChildProcess | null = null;
122
+ private current: AgentEnvironmentProcess | null = null;
114
123
  private queue: Promise<void> = Promise.resolve();
115
124
  private closed = false;
116
125
 
117
126
  constructor(args: {
118
127
  taskId: string;
119
128
  agent: RosterAgent;
120
- containerName: string;
129
+ environment: TaskEnvironmentAgentSessionSurface;
121
130
  systemPreamble: string;
122
131
  agentEnv?: Record<string, string>;
123
132
  spawner?: Spawner;
124
133
  }) {
125
134
  this.agentId = args.agent.id;
126
- this.containerName = args.containerName;
135
+ this.environment = args.environment;
127
136
  this.model = args.agent.model;
128
137
  this.systemPreamble = args.systemPreamble;
129
138
  this.agentEnv = args.agentEnv ?? {};
130
- this.spawner = args.spawner ?? defaultSpawn;
139
+ this.spawner =
140
+ args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
131
141
  }
132
142
 
133
143
  onEvent(handler: AgentEventHandler): () => void {
@@ -142,22 +152,18 @@ export class GrokSession implements AgentSession {
142
152
 
143
153
  async send(text: string): Promise<void> {
144
154
  if (this.closed) return;
145
- this.queue = this.queue.then(() => this.runTurn(text));
146
- return this.queue;
155
+ const turn = this.queue.catch(() => {}).then(() => this.runTurn(text));
156
+ this.queue = turn;
157
+ return turn;
147
158
  }
148
159
 
149
- private buildArgs(prompt: string): string[] {
150
- const args = ["exec", "-i", "-u", "node"];
160
+ private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
151
161
  // Host-resident API key (pasted in the engines panel, ADR-015): forward
152
162
  // name-only — docker takes the value from the client env, keeping the
153
163
  // secret out of argv. Subscription logins need nothing here (task-up
154
164
  // copies ~/.grok into the container instead).
155
- if (process.env.XAI_API_KEY) args.push("-e", "XAI_API_KEY");
156
- for (const [k, v] of Object.entries(this.agentEnv)) {
157
- args.push("-e", `${k}=${v}`);
158
- }
159
- args.push(
160
- this.containerName,
165
+ const inheritEnv = process.env.XAI_API_KEY ? ["XAI_API_KEY"] : [];
166
+ const argv = [
161
167
  GROK_BIN,
162
168
  "-p",
163
169
  prompt,
@@ -166,19 +172,26 @@ export class GrokSession implements AgentSession {
166
172
  // The container is the isolation boundary — auto-run tools.
167
173
  "--permission-mode",
168
174
  "bypassPermissions",
169
- );
170
- if (this.model) args.push("-m", this.model);
175
+ ];
176
+ if (this.model) argv.push("-m", this.model);
171
177
  if (this.systemPreamble.trim().length > 0) {
172
- args.push("--system-prompt-override", this.systemPreamble);
178
+ argv.push("--system-prompt-override", this.systemPreamble);
173
179
  }
174
180
  // Resume the same conversation across turns (captured from the last end).
175
- if (this.sessionId) args.push("-r", this.sessionId);
176
- return args;
181
+ if (this.sessionId) argv.push("-r", this.sessionId);
182
+ return {
183
+ argv: argv as [string, ...string[]],
184
+ user: "node",
185
+ inheritEnv,
186
+ env: this.agentEnv,
187
+ maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
188
+ };
177
189
  }
178
190
 
179
191
  private async runTurn(text: string): Promise<void> {
180
192
  if (this.closed) return;
181
- const child = this.spawner(this.buildArgs(text));
193
+ requireContainerRuntimeOperational();
194
+ const child = this.spawner(this.buildRequest(text));
182
195
  this.current = child;
183
196
 
184
197
  let acc = "";
@@ -256,6 +269,6 @@ register({
256
269
  supportedEfforts: () => [...GROK_EFFORTS],
257
270
  available: () =>
258
271
  existsSync(grokAuthPath()) || Boolean(process.env.XAI_API_KEY),
259
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
260
- new GrokSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
272
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
273
+ new GrokSession({ taskId, agent, environment, systemPreamble, agentEnv }),
261
274
  });
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * KimiSession — a real AgentSession backed by Moonshot's **Kimi Code** CLI,
3
- * run inside the task container:
3
+ * run inside the task environment:
4
4
  *
5
- * docker exec -i task-<id>-app-1 ~/.kimi-code/bin/kimi \
5
+ * ~/.kimi-code/bin/kimi \
6
6
  * -p "<prompt>" -m kimi-code/k3 --output-format stream-json [-r <sessionId>]
7
7
  *
8
8
  * Unlike Claude (persistent stdin session) and Codex (`app-server` JSON-RPC),
9
9
  * Kimi Code is **one-shot per turn**: `-p` runs a single prompt to completion,
10
10
  * streams newline-delimited JSON to stdout, and exits. Multi-turn continuity is
11
11
  * `-r <sessionId>` (the session lives in Kimi's cloud + the container's
12
- * ~/.kimi-code). So each `send()` spawns a fresh `docker exec`; sends are
12
+ * ~/.kimi-code). So each `send()` spawns a fresh provider process; sends are
13
13
  * serialized so a second message can't race a running turn.
14
14
  *
15
15
  * Stream-json vocabulary (reverse-engineered — OpenAI-chat-style role lines):
@@ -24,12 +24,21 @@
24
24
  * `-p` runs non-interactively and auto-runs tools (the container is the
25
25
  * isolation boundary), so there are no permission prompts to resolve.
26
26
  */
27
- import { spawn, type ChildProcess } from "node:child_process";
28
27
  import { existsSync } from "node:fs";
29
28
  import { homedir } from "node:os";
30
29
  import { join } from "node:path";
31
30
 
31
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
32
+ import type {
33
+ TaskEnvironmentAgentSessionSurface,
34
+ TaskEnvironmentSessionRequest,
35
+ } from "../task-environment/types";
32
36
  import { newId } from "../ulid";
37
+ import {
38
+ AGENT_SESSION_OUTPUT_BUFFER_BYTES,
39
+ spawnEnvironmentProcess,
40
+ type AgentEnvironmentProcess,
41
+ } from "./proc";
33
42
  import { register } from "./registry";
34
43
  import type {
35
44
  AgentEvent,
@@ -40,7 +49,7 @@ import type {
40
49
  } from "./types";
41
50
 
42
51
  /** Installed by the image's install.sh (code.kimi.com); absolute so a
43
- * non-login `docker exec` doesn't depend on PATH. */
52
+ * non-login provider process doesn't depend on PATH. */
44
53
  const KIMI_BIN = "/home/node/.kimi-code/bin/kimi";
45
54
 
46
55
  // Model ids from `~/.kimi-code/config.toml` (`kimi -m <id>`). k3 is the
@@ -143,15 +152,15 @@ export function mapKimiLine(line: string): MappedLine {
143
152
  // ---------------------------------------------------------------------------
144
153
 
145
154
  /** Injectable spawn seam (tests provide a fake). */
146
- export type Spawner = (args: string[]) => ChildProcess;
147
- const defaultSpawn: Spawner = (args) =>
148
- spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
155
+ export type Spawner = (
156
+ request: TaskEnvironmentSessionRequest,
157
+ ) => AgentEnvironmentProcess;
149
158
 
150
159
  export class KimiSession implements AgentSession {
151
160
  readonly agentId: string;
152
161
  readonly kind: AgentKind = "kimi";
153
162
 
154
- private readonly containerName: string;
163
+ private readonly environment: TaskEnvironmentAgentSessionSurface;
155
164
  private readonly model?: string;
156
165
  private readonly systemPreamble: string;
157
166
  private readonly agentEnv: Record<string, string>;
@@ -159,7 +168,7 @@ export class KimiSession implements AgentSession {
159
168
 
160
169
  private readonly handlers = new Set<AgentEventHandler>();
161
170
  private sessionId: string | null = null;
162
- private current: ChildProcess | null = null;
171
+ private current: AgentEnvironmentProcess | null = null;
163
172
  private queue: Promise<void> = Promise.resolve();
164
173
  private sentPreamble = false;
165
174
  private closed = false;
@@ -167,17 +176,18 @@ export class KimiSession implements AgentSession {
167
176
  constructor(args: {
168
177
  taskId: string;
169
178
  agent: RosterAgent;
170
- containerName: string;
179
+ environment: TaskEnvironmentAgentSessionSurface;
171
180
  systemPreamble: string;
172
181
  agentEnv?: Record<string, string>;
173
182
  spawner?: Spawner;
174
183
  }) {
175
184
  this.agentId = args.agent.id;
176
- this.containerName = args.containerName;
185
+ this.environment = args.environment;
177
186
  this.model = args.agent.model;
178
187
  this.systemPreamble = args.systemPreamble;
179
188
  this.agentEnv = args.agentEnv ?? {};
180
- this.spawner = args.spawner ?? defaultSpawn;
189
+ this.spawner =
190
+ args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
181
191
  }
182
192
 
183
193
  onEvent(handler: AgentEventHandler): () => void {
@@ -194,32 +204,37 @@ export class KimiSession implements AgentSession {
194
204
  * running one instead of spawning a racing `kimi -p`. */
195
205
  async send(text: string): Promise<void> {
196
206
  if (this.closed) return;
197
- this.queue = this.queue.then(() => this.runTurn(text));
198
- return this.queue;
207
+ const turn = this.queue.catch(() => {}).then(() => this.runTurn(text));
208
+ this.queue = turn;
209
+ return turn;
199
210
  }
200
211
 
201
- private buildArgs(prompt: string): string[] {
202
- const args = ["exec", "-i", "-u", "node"];
203
- for (const [k, v] of Object.entries(this.agentEnv)) {
204
- args.push("-e", `${k}=${v}`);
205
- }
206
- args.push(
207
- this.containerName,
212
+ private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
213
+ const argv = [
208
214
  KIMI_BIN,
209
215
  "-p",
210
216
  prompt,
211
217
  "--output-format",
212
218
  "stream-json",
213
- );
214
- if (this.model) args.push("-m", this.model);
219
+ ];
220
+ if (this.model) argv.push("-m", this.model);
215
221
  // Continue the same Kimi session across turns (captured from the first
216
222
  // turn's resume_hint). First turn has none → a fresh session.
217
- if (this.sessionId) args.push("-r", this.sessionId);
218
- return args;
223
+ if (this.sessionId) argv.push("-r", this.sessionId);
224
+ return {
225
+ argv: argv as [string, ...string[]],
226
+ user: "node",
227
+ env: this.agentEnv,
228
+ maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
229
+ };
219
230
  }
220
231
 
221
232
  private async runTurn(text: string): Promise<void> {
222
233
  if (this.closed) return;
234
+ // A turn may have spent minutes queued behind its predecessor. Admission
235
+ // at send() time is stale; this synchronous check immediately precedes the
236
+ // provider process launch and leaves the first-turn preamble unconsumed on fail.
237
+ requireContainerRuntimeOperational();
223
238
  // Kimi's `-p` has no system-prompt flag, so fold the channel briefing into
224
239
  // the FIRST turn's prompt (later turns carry it via the resumed session).
225
240
  const prompt =
@@ -228,7 +243,7 @@ export class KimiSession implements AgentSession {
228
243
  : text;
229
244
  this.sentPreamble = true;
230
245
 
231
- const child = this.spawner(this.buildArgs(prompt));
246
+ const child = this.spawner(this.buildRequest(prompt));
232
247
  this.current = child;
233
248
 
234
249
  let buf = "";
@@ -308,6 +323,6 @@ register({
308
323
  // Gated on the operator's Kimi Code subscription credential (copied into
309
324
  // containers at task-up). No login → the engine isn't advertised.
310
325
  available: () => existsSync(kimiCredPath()),
311
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
312
- new KimiSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
326
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
327
+ new KimiSession({ taskId, agent, environment, systemPreamble, agentEnv }),
313
328
  });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * OpencodeSession — a real AgentSession backed by the OpenCode CLI's
3
- * non-interactive `run` mode, executed inside the task container (ADR-077):
3
+ * non-interactive `run` mode, executed inside the task environment (ADR-077):
4
4
  *
5
- * docker exec -i task-<id>-app-1 ~/.opencode/bin/opencode run \
5
+ * ~/.opencode/bin/opencode run \
6
6
  * --format json --auto --model <provider/model> [--variant <effort>] \
7
7
  * [--session <id>] "<prompt>"
8
8
  *
@@ -10,7 +10,7 @@
10
10
  * `run` executes a single prompt to completion, streams newline-delimited JSON
11
11
  * events to stdout, and exits. Multi-turn continuity is `--session <id>` (the
12
12
  * id is captured from the first turn's event stream). Each `send()` spawns a
13
- * fresh `docker exec`; sends are serialized so a second message can't race a
13
+ * fresh provider process; sends are serialized so a second message can't race a
14
14
  * running turn.
15
15
  *
16
16
  * `--format json` vocabulary (one object per line, `type` field):
@@ -29,12 +29,21 @@
29
29
  * there are no permission prompts to resolve. ADR-076: `OPENCODE_DATA_DIR` in
30
30
  * agentEnv selects which account's data dir (auth) this exec uses.
31
31
  */
32
- import { spawn, type ChildProcess } from "node:child_process";
33
32
  import { existsSync } from "node:fs";
34
33
  import { homedir } from "node:os";
35
34
  import { join } from "node:path";
36
35
 
36
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
37
+ import type {
38
+ TaskEnvironmentAgentSessionSurface,
39
+ TaskEnvironmentSessionRequest,
40
+ } from "../task-environment/types";
37
41
  import { newId } from "../ulid";
42
+ import {
43
+ AGENT_SESSION_OUTPUT_BUFFER_BYTES,
44
+ spawnEnvironmentProcess,
45
+ type AgentEnvironmentProcess,
46
+ } from "./proc";
38
47
  import { isRateLimitMessage } from "./rate-limit";
39
48
  import { register } from "./registry";
40
49
  import type {
@@ -47,7 +56,7 @@ import type {
47
56
  } from "./types";
48
57
 
49
58
  /** Installed by the image's opencode installer (opencode.ai/install); absolute
50
- * so a non-login `docker exec` doesn't depend on PATH. */
59
+ * so a non-login provider process doesn't depend on PATH. */
51
60
  const OPENCODE_BIN = "/home/node/.opencode/bin/opencode";
52
61
 
53
62
  // OpenCode is multi-provider: models are `provider/model` strings. This is a
@@ -257,9 +266,9 @@ export function mapOpencodeLine(line: string): MappedOpencodeLine {
257
266
  // ---------------------------------------------------------------------------
258
267
 
259
268
  /** Injectable spawn seam (tests provide a fake). */
260
- export type Spawner = (args: string[]) => ChildProcess;
261
- const defaultSpawn: Spawner = (args) =>
262
- spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
269
+ export type Spawner = (
270
+ request: TaskEnvironmentSessionRequest,
271
+ ) => AgentEnvironmentProcess;
263
272
 
264
273
  /** Sum this turn's step usage into a running total. */
265
274
  function addUsage(acc: AgentUsage, u: AgentUsage): AgentUsage {
@@ -278,7 +287,7 @@ export class OpencodeSession implements AgentSession {
278
287
  readonly agentId: string;
279
288
  readonly kind: AgentKind = "opencode";
280
289
 
281
- private readonly containerName: string;
290
+ private readonly environment: TaskEnvironmentAgentSessionSurface;
282
291
  private readonly model?: string;
283
292
  private readonly effort?: string;
284
293
  private readonly systemPreamble: string;
@@ -287,7 +296,7 @@ export class OpencodeSession implements AgentSession {
287
296
 
288
297
  private readonly handlers = new Set<AgentEventHandler>();
289
298
  private sessionId: string | null = null;
290
- private current: ChildProcess | null = null;
299
+ private current: AgentEnvironmentProcess | null = null;
291
300
  private queue: Promise<void> = Promise.resolve();
292
301
  private sentPreamble = false;
293
302
  private closed = false;
@@ -295,18 +304,19 @@ export class OpencodeSession implements AgentSession {
295
304
  constructor(args: {
296
305
  taskId: string;
297
306
  agent: RosterAgent;
298
- containerName: string;
307
+ environment: TaskEnvironmentAgentSessionSurface;
299
308
  systemPreamble: string;
300
309
  agentEnv?: Record<string, string>;
301
310
  spawner?: Spawner;
302
311
  }) {
303
312
  this.agentId = args.agent.id;
304
- this.containerName = args.containerName;
313
+ this.environment = args.environment;
305
314
  this.model = args.agent.model;
306
315
  this.effort = args.agent.effort;
307
316
  this.systemPreamble = args.systemPreamble;
308
317
  this.agentEnv = args.agentEnv ?? {};
309
- this.spawner = args.spawner ?? defaultSpawn;
318
+ this.spawner =
319
+ args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
310
320
  }
311
321
 
312
322
  onEvent(handler: AgentEventHandler): () => void {
@@ -323,17 +333,13 @@ export class OpencodeSession implements AgentSession {
323
333
  * running one instead of spawning a racing `opencode run`. */
324
334
  async send(text: string): Promise<void> {
325
335
  if (this.closed) return;
326
- this.queue = this.queue.then(() => this.runTurn(text));
327
- return this.queue;
336
+ const turn = this.queue.catch(() => {}).then(() => this.runTurn(text));
337
+ this.queue = turn;
338
+ return turn;
328
339
  }
329
340
 
330
- private buildArgs(prompt: string): string[] {
331
- const args = ["exec", "-i", "-u", "node"];
332
- for (const [k, v] of Object.entries(this.agentEnv)) {
333
- args.push("-e", `${k}=${v}`);
334
- }
335
- args.push(
336
- this.containerName,
341
+ private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
342
+ const argv = [
337
343
  OPENCODE_BIN,
338
344
  "run",
339
345
  "--format",
@@ -341,19 +347,25 @@ export class OpencodeSession implements AgentSession {
341
347
  // Auto-approve permissions not explicitly denied — the container is the
342
348
  // sandbox, and headless has no interactive approver.
343
349
  "--auto",
344
- );
345
- if (this.model) args.push("--model", this.model);
346
- if (this.effort) args.push("--variant", this.effort);
350
+ ];
351
+ if (this.model) argv.push("--model", this.model);
352
+ if (this.effort) argv.push("--variant", this.effort);
347
353
  // Continue the same OpenCode session across turns (captured from the first
348
354
  // turn's stream). First turn has none → a fresh session.
349
- if (this.sessionId) args.push("--session", this.sessionId);
355
+ if (this.sessionId) argv.push("--session", this.sessionId);
350
356
  // The prompt is positional; put it LAST so it's never mistaken for a flag.
351
- args.push(prompt);
352
- return args;
357
+ argv.push(prompt);
358
+ return {
359
+ argv: argv as [string, ...string[]],
360
+ user: "node",
361
+ env: this.agentEnv,
362
+ maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
363
+ };
353
364
  }
354
365
 
355
366
  private async runTurn(text: string): Promise<void> {
356
367
  if (this.closed) return;
368
+ requireContainerRuntimeOperational();
357
369
  // `run` has no system-prompt flag, so fold the channel briefing into the
358
370
  // FIRST turn's prompt (later turns carry it via the resumed session).
359
371
  const prompt =
@@ -362,7 +374,7 @@ export class OpencodeSession implements AgentSession {
362
374
  : text;
363
375
  this.sentPreamble = true;
364
376
 
365
- const child = this.spawner(this.buildArgs(prompt));
377
+ const child = this.spawner(this.buildRequest(prompt));
366
378
  this.current = child;
367
379
 
368
380
  let turnUsage: AgentUsage = {};
@@ -467,6 +479,6 @@ register({
467
479
  // Gated on the operator's OpenCode auth (copied into containers at task-up).
468
480
  // No auth → the engine isn't advertised.
469
481
  available: () => existsSync(opencodeAuthPath()),
470
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
471
- new OpencodeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
482
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
483
+ new OpencodeSession({ taskId, agent, environment, systemPreamble, agentEnv }),
472
484
  });