@runuai/host 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
@@ -0,0 +1,36 @@
1
+ {
2
+ "//": "uai — slimmed code-server defaults. Seeded into the User settings",
3
+ "//2": "dir by uai-init when none exists yet. Trims welcome/telemetry/chat",
4
+ "//3": "chrome so the Editor pane is a focused editor, not a full AI-IDE.",
5
+
6
+ "telemetry.telemetryLevel": "off",
7
+ "workbench.startupEditor": "none",
8
+ "workbench.tips.enabled": false,
9
+ "workbench.welcomePage.walkthroughs.openOnInstall": false,
10
+ "window.commandCenter": false,
11
+ "chat.commandCenter.enabled": false,
12
+ "update.mode": "none",
13
+ "extensions.autoCheckUpdates": false,
14
+ "extensions.autoUpdate": false,
15
+ "git.openRepositoryInParentFolders": "always",
16
+ "workbench.activityBar.location": "top",
17
+ "chat.viewSessions.enabled": false,
18
+ "workbench.secondarySideBar.defaultVisibility": "hidden",
19
+ "workbench.colorTheme": "Dark 2026",
20
+
21
+ "//4": "Workspace Trust — every task runs in an isolated container off the",
22
+ "//5": "user's own repos. The 'do you trust this folder?' prompt is pure",
23
+ "//6": "friction here; the user already trusts their code and the container",
24
+ "//7": "is the security boundary.",
25
+ "security.workspace.trust.enabled": false,
26
+
27
+ "//8": "Port forwarding — code-server's own auto-forward / 'Open in Browser'",
28
+ "//9": "popup can't reach anything useful here: dev servers are exposed",
29
+ "//10": "through uai's preview tunnel (ADR-025), not code-server's forwarder.",
30
+ "//11": "Disable it so the popup doesn't mislead.",
31
+ "remote.autoForwardPorts": false,
32
+ "remote.restoreForwardedPorts": false,
33
+
34
+ "//12": "Integrated terminal uses zsh + oh-my-zsh (the node login shell).",
35
+ "terminal.integrated.defaultProfile.linux": "zsh"
36
+ }
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bash
2
+ # uai-init — prepare a standard-image task container (ADR-022).
3
+ #
4
+ # Runs INSIDE the container (Linux, bash 5 — may use anything). Invoked by
5
+ # the host via `docker exec <app> /usr/local/bin/uai-init` after compose up.
6
+ # Spec: ../../../docs/runtime.md §uai-init.
7
+ #
8
+ # Workspace layout (ADR-022): /workspace/<project-slug>/ — one git worktree
9
+ # per selected project. A 0-project (scratchpad) task has an empty /workspace.
10
+ #
11
+ # Responsibilities:
12
+ # 1. For each /workspace/<slug>/ that carries a .tool-versions: `asdf
13
+ # install` (downloads cached on the host-wide /opt/asdf-data volume),
14
+ # then a dependency-install heuristic for the folder's stack.
15
+ # 2. Launch code-server on 0.0.0.0:8080 (auth disabled — reached only via
16
+ # the authenticated tunnel), opening /workspace.
17
+ #
18
+ # Best-effort + idempotent: a failing folder logs and is skipped; it never
19
+ # aborts the whole init. Re-running when things are already up is a no-op.
20
+ # uai-init itself always exits 0 so task-up's `docker exec ... uai-init` step
21
+ # succeeds as long as the container is reachable.
22
+
23
+ set -uo pipefail
24
+
25
+ WORKSPACE="${UAI_WORKSPACE:-/workspace}"
26
+ ASDF_SH="${ASDF_DIR:-/opt/asdf}/asdf.sh"
27
+
28
+ log() { echo "uai-init: $*"; }
29
+
30
+ # Make asdf + its shims available to this non-interactive shell. A bare
31
+ # `docker exec` sources no profile, so source asdf explicitly here.
32
+ if [ -f "$ASDF_SH" ]; then
33
+ # shellcheck disable=SC1090
34
+ . "$ASDF_SH"
35
+ else
36
+ log "warning: asdf not found at $ASDF_SH; runtime install will be skipped"
37
+ fi
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # 0. GitHub auth for in-container git (ADR-027). git rides the host's uai SSH
41
+ # identity (docker-cp'd to ~/.ssh, registered on GitHub for auth + signing),
42
+ # NOT an HTTPS token. Route every GitHub remote — including https:// ones —
43
+ # over SSH so a project's clone scheme doesn't matter, and trust github's
44
+ # host key non-interactively so a fresh container's first push doesn't stall
45
+ # on a prompt. `gh` is separate: it authenticates from its own config file,
46
+ # which the host writes via `gh auth login --with-token` with the user's
47
+ # short-lived token. We must NOT set GH_TOKEN in the env — `gh` would prefer
48
+ # it over that stored credential (and re-attribute every PR to it).
49
+ # ---------------------------------------------------------------------------
50
+
51
+ if [ -f "$HOME/.ssh/id_ed25519" ]; then
52
+ log "routing git GitHub remotes over the uai SSH identity"
53
+ git config --global url."git@github.com:".insteadOf "https://github.com/"
54
+ git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"
55
+ fi
56
+
57
+ # Signed commits (policy): when the uai SSH identity is present, configure git
58
+ # to SSH-sign every commit + tag with it. The pubkey is registered on GitHub
59
+ # (via `setup-identity`) so commits show as Verified. The same key also carries
60
+ # git push over SSH (configured above).
61
+ if [ -f "$HOME/.ssh/id_ed25519.pub" ]; then
62
+ log "enabling SSH commit signing with the uai identity"
63
+ git config --global gpg.format ssh
64
+ git config --global user.signingkey "$HOME/.ssh/id_ed25519.pub"
65
+ git config --global commit.gpgsign true
66
+ git config --global tag.gpgsign true
67
+ fi
68
+
69
+ # Attribution policy: never co-author with the agents. Claude Code honors
70
+ # `includeCoAuthoredBy: false` (drops the "Co-Authored-By: Claude" commit
71
+ # trailer and the "Generated with Claude Code" PR footer). Seed it into the
72
+ # per-task Claude user settings if absent. Codex is covered by the
73
+ # orchestrator's system preamble.
74
+ cc_settings="$HOME/.claude/settings.json"
75
+ if [ ! -f "$cc_settings" ]; then
76
+ mkdir -p "$HOME/.claude"
77
+ printf '{\n "includeCoAuthoredBy": false\n}\n' > "$cc_settings"
78
+ log "seeded ~/.claude/settings.json (includeCoAuthoredBy: false)"
79
+ fi
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # 1. Per-folder runtime + dependency install.
83
+ # ---------------------------------------------------------------------------
84
+
85
+ install_node_deps() {
86
+ # Pick the package manager from the packageManager field, else a lockfile,
87
+ # else npm. corepack provisions pnpm/yarn from the packageManager field.
88
+ local pm=""
89
+ if [ -f package.json ]; then
90
+ pm=$(jq -r '.packageManager // ""' package.json 2>/dev/null | sed 's/@.*//')
91
+ fi
92
+ if [ -z "$pm" ]; then
93
+ if [ -f pnpm-lock.yaml ]; then pm="pnpm"
94
+ elif [ -f yarn.lock ]; then pm="yarn"
95
+ elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then pm="npm"
96
+ elif [ -f bun.lockb ] || [ -f bun.lock ]; then pm="bun"
97
+ else pm="npm"
98
+ fi
99
+ fi
100
+
101
+ # corepack ships with Node and resolves pnpm/yarn per the packageManager
102
+ # field; harmless no-op when already enabled.
103
+ corepack enable >/dev/null 2>&1 || true
104
+
105
+ case "$pm" in
106
+ pnpm) log "pnpm install"; pnpm install ;;
107
+ yarn) log "yarn install"; yarn install ;;
108
+ bun) log "bun install"; bun install ;;
109
+ *) log "npm install"; npm install --no-audit --no-fund ;;
110
+ esac
111
+ }
112
+
113
+ install_python_deps() {
114
+ # uv handles both requirements.txt and pyproject.toml; create a project .venv
115
+ # so the install is isolated to the folder. Pin uv to ASDF's Python (the
116
+ # version asdf just resolved from the in-scope .tool-versions) so the venv
117
+ # matches the project's declared runtime. uv otherwise selects via
118
+ # .python-version / requires-python, which can disagree with .tool-versions
119
+ # (or be junk, e.g. a stray pyenv-virtualenv name) — asdf is the authority.
120
+ local py
121
+ py="$(asdf which python 2>/dev/null || true)"
122
+ if [ -f pyproject.toml ]; then
123
+ log "uv sync (pyproject.toml)${py:+ — asdf python $py}"
124
+ if [ -n "$py" ]; then uv sync --python "$py"; else uv sync; fi
125
+ elif [ -f requirements.txt ]; then
126
+ log "uv venv + pip install -r requirements.txt${py:+ — asdf python $py}"
127
+ if [ -n "$py" ]; then uv venv --python "$py"; else uv venv; fi \
128
+ && uv pip install -r requirements.txt
129
+ fi
130
+ }
131
+
132
+ install_folder() {
133
+ local dir="$1" name
134
+ name=$(basename "$dir")
135
+
136
+ # Enter the folder; asdf auto-resolves the nearest .tool-versions here.
137
+ cd "$dir" || { log "cannot cd into $name — skipping"; return; }
138
+
139
+ # Runtime install. asdf reads the in-scope .tool-versions and downloads any
140
+ # missing versions (cached on the /opt/asdf-data volume).
141
+ if command -v asdf >/dev/null 2>&1; then
142
+ log "asdf install ($name)"
143
+ asdf install || log "asdf install failed in $name — continuing"
144
+ fi
145
+
146
+ # Dependency-install heuristic. Run every matcher that applies (a polyglot
147
+ # folder may have more than one); each is independently best-effort.
148
+ if [ -f package.json ]; then
149
+ install_node_deps || log "node deps failed in $name — continuing"
150
+ fi
151
+ if [ -f pyproject.toml ] || [ -f requirements.txt ]; then
152
+ install_python_deps || log "python deps failed in $name — continuing"
153
+ fi
154
+ if [ -f Cargo.toml ]; then
155
+ log "cargo build ($name)"
156
+ cargo build || log "cargo build failed in $name — continuing"
157
+ fi
158
+ if [ -f go.mod ]; then
159
+ log "go mod download ($name)"
160
+ go mod download || log "go mod download failed in $name — continuing"
161
+ fi
162
+ }
163
+
164
+ if [ -d "$WORKSPACE" ]; then
165
+ shopt -s nullglob
166
+ for folder in "$WORKSPACE"/*/; do
167
+ [ -d "$folder" ] || continue
168
+ if [ -f "${folder}.tool-versions" ]; then
169
+ # Run in a subshell so a `cd` (or a failing command under the relaxed
170
+ # error mode) in one folder never leaks into the next.
171
+ ( install_folder "$folder" )
172
+ else
173
+ log "no .tool-versions in $(basename "$folder") — skipping per-folder install"
174
+ fi
175
+ done
176
+ shopt -u nullglob
177
+ else
178
+ log "workspace $WORKSPACE missing — scratchpad/no-project task, skipping installs"
179
+ fi
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # 2. code-server — the Editor pane. Bound to 0.0.0.0:8080, auth disabled
183
+ # (reached only through the authenticated tunnel / editor proxy). Opens
184
+ # /workspace so all worktrees are visible at the top level. Idempotent:
185
+ # skips launch if already running.
186
+ # ---------------------------------------------------------------------------
187
+
188
+ editor_root="$WORKSPACE"
189
+ [ -d "$editor_root" ] || editor_root="/home/node"
190
+
191
+ # Seed the curated code-server defaults (slim chrome, telemetry off, trust
192
+ # off) into the User settings dir when the task hasn't written its own yet.
193
+ cs_user_dir="$HOME/.local/share/code-server/User"
194
+ cs_seed="/usr/local/share/uai/code-server-settings.json"
195
+ if [ -f "$cs_seed" ] && [ ! -f "$cs_user_dir/settings.json" ]; then
196
+ mkdir -p "$cs_user_dir"
197
+ cp "$cs_seed" "$cs_user_dir/settings.json"
198
+ log "seeded code-server settings"
199
+ fi
200
+
201
+ if pgrep -f 'code-server.*--bind-addr' >/dev/null 2>&1; then
202
+ log "code-server already running"
203
+ else
204
+ log "launching code-server on 0.0.0.0:8080"
205
+ nohup code-server \
206
+ --auth none \
207
+ --disable-telemetry \
208
+ --bind-addr 0.0.0.0:8080 \
209
+ "$editor_root" \
210
+ >/tmp/code-server.log 2>&1 &
211
+ disown
212
+ fi
213
+
214
+ # Always succeed: the container is up and code-server has been (re)launched.
215
+ exit 0
@@ -0,0 +1,2 @@
1
+ nodejs 22.11.0
2
+ python 3.12.4
package/lib/agent.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Typed wrapper around the host-agent shell scripts.
3
+ *
4
+ * The agent's wire contract (docs/agent.md):
5
+ * success → stdout: { ok: true, data: ... }
6
+ * failure → stderr: { ok: false, error: { code, message, step?, exit_code? } }
7
+ * non-zero exit on failure.
8
+ *
9
+ * This module shells out to scripts/agent/*.sh (or UAI_AGENT_DIR/*.sh in
10
+ * production), pipes UAI_DB_PATH + UAI_DATA_DIR into the child, parses
11
+ * the JSON, and returns typed result objects — or throws AgentError.
12
+ *
13
+ * Same-process for MVP (per ADR-004). When uai is extracted to a hosted
14
+ * product, this module becomes a JSON-RPC client over a websocket; the
15
+ * surface stays the same.
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { z } from "zod";
22
+
23
+ import {
24
+ createTaskDownCommandDb,
25
+ createTaskStatusCommandDb,
26
+ createTaskUpCommandDb,
27
+ removeCommandDb,
28
+ } from "./command-db";
29
+ import { env } from "./env";
30
+ import { PreviewPortRuntimesSchema } from "./preview-ports";
31
+ import { getHostTask } from "./runtime-state";
32
+ import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
33
+ import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Locate the agent scripts. They ship inside the package, so by default we
37
+ // resolve them relative to this module — which works identically in a repo
38
+ // checkout (host-agent/scripts/agent) and an npm install
39
+ // (node_modules/@runuai/host/scripts/agent). An explicit UAI_AGENT_DIR
40
+ // overrides this (e.g. hacking on scripts against a running host).
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function packageAgentScriptsDir(): string {
44
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "agent");
45
+ }
46
+
47
+ function agentDir(): string {
48
+ return env.agentDir ?? packageAgentScriptsDir();
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Result schemas. Each command shares the success envelope and produces a
53
+ // command-specific `data` payload.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const ErrorPayload = z.object({
57
+ code: z.string(),
58
+ message: z.string(),
59
+ step: z.string().optional(),
60
+ exit_code: z.number().optional(),
61
+ });
62
+
63
+ const SuccessEnvelope = <T extends z.ZodTypeAny>(data: T) =>
64
+ z.object({ ok: z.literal(true), data });
65
+
66
+ const FailureEnvelope = z.object({
67
+ ok: z.literal(false),
68
+ error: ErrorPayload,
69
+ });
70
+
71
+ // task-up
72
+ const TaskUpData = z.object({
73
+ composeProject: z.string(),
74
+ worktreePath: z.string(),
75
+ codeServerPort: z.number().int().positive().optional(),
76
+ previewPorts: PreviewPortRuntimesSchema.optional(),
77
+ });
78
+ export type TaskUpResult = z.infer<typeof TaskUpData>;
79
+
80
+ // task-down
81
+ const TaskDownData = z
82
+ .object({
83
+ status: z.string().optional(),
84
+ alreadyGone: z.boolean().optional(),
85
+ })
86
+ .passthrough();
87
+ export type TaskDownResult = z.infer<typeof TaskDownData>;
88
+
89
+ // task-status
90
+ const TaskStatusData = z.object({
91
+ composeRunning: z.boolean(),
92
+ containers: z.array(z.string()),
93
+ worktreePresent: z.boolean(),
94
+ });
95
+ export type TaskStatusResult = z.infer<typeof TaskStatusData>;
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Error type. All call sites should `catch (err)` and use `AgentError.is`.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export class AgentError extends Error {
102
+ override readonly name = "AgentError";
103
+ readonly code: string;
104
+ readonly step: string | undefined;
105
+ readonly exitCode: number | undefined;
106
+
107
+ constructor(
108
+ code: string,
109
+ message: string,
110
+ options: { step?: string; exitCode?: number; cause?: unknown } = {},
111
+ ) {
112
+ super(
113
+ message,
114
+ options.cause === undefined ? undefined : { cause: options.cause },
115
+ );
116
+ this.code = code;
117
+ this.step = options.step;
118
+ this.exitCode = options.exitCode;
119
+ }
120
+
121
+ static is(err: unknown): err is AgentError {
122
+ return err instanceof AgentError;
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Internal: run an agent script and parse the JSON envelope.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ async function runAgent<T extends z.ZodTypeAny>(
131
+ scriptName: string,
132
+ args: string[],
133
+ dataSchema: T,
134
+ commandDbPath: string,
135
+ extraEnv: Record<string, string> = {},
136
+ ): Promise<z.infer<T>> {
137
+ const scriptPath = resolve(agentDir(), scriptName);
138
+
139
+ const child = spawn(scriptPath, args, {
140
+ stdio: ["ignore", "pipe", "pipe"],
141
+ env: {
142
+ ...process.env,
143
+ UAI_DB_PATH: commandDbPath,
144
+ UAI_DATA_DIR: env.dataDir,
145
+ ...extraEnv,
146
+ },
147
+ });
148
+
149
+ let stdoutBuf = "";
150
+ let stderrBuf = "";
151
+ child.stdout.on("data", (chunk) => {
152
+ stdoutBuf += chunk.toString("utf8");
153
+ });
154
+ child.stderr.on("data", (chunk) => {
155
+ stderrBuf += chunk.toString("utf8");
156
+ });
157
+
158
+ const exitCode: number = await new Promise((res, rej) => {
159
+ child.on("error", rej);
160
+ child.on("close", (code) => res(code ?? 0));
161
+ });
162
+
163
+ // Try to parse whichever stream has the JSON. On failure the agent puts
164
+ // its envelope on stderr; on success, on stdout.
165
+ if (exitCode !== 0) {
166
+ // Log the full stderr so the dev terminal shows the underlying tool's
167
+ // output (docker's stderr, git's stderr, etc.) — the shell agent only
168
+ // surfaces the code + step on the structured envelope.
169
+ if (stderrBuf.trim()) {
170
+ console.error(
171
+ `[uai-agent] ${scriptName} stderr (exit ${exitCode}):\n${stderrBuf.trim()}`,
172
+ );
173
+ }
174
+ const parsed = tryParseLastJsonLine(stderrBuf);
175
+ if (parsed) {
176
+ const failure = FailureEnvelope.safeParse(parsed);
177
+ if (failure.success) {
178
+ throw new AgentError(
179
+ failure.data.error.code,
180
+ failure.data.error.message,
181
+ {
182
+ step: failure.data.error.step,
183
+ exitCode: failure.data.error.exit_code ?? exitCode,
184
+ },
185
+ );
186
+ }
187
+ }
188
+ throw new AgentError(
189
+ "AGENT_UNKNOWN_FAILURE",
190
+ `${scriptName} exited ${exitCode}: ${stderrBuf.trim() || "no stderr"}`,
191
+ { exitCode },
192
+ );
193
+ }
194
+
195
+ const parsed = tryParseLastJsonLine(stdoutBuf);
196
+ if (!parsed) {
197
+ throw new AgentError(
198
+ "AGENT_BAD_OUTPUT",
199
+ `${scriptName} stdout was not JSON: ${stdoutBuf.trim().slice(0, 200)}`,
200
+ { exitCode },
201
+ );
202
+ }
203
+ const envelope = SuccessEnvelope(dataSchema).safeParse(parsed);
204
+ if (!envelope.success) {
205
+ throw new AgentError(
206
+ "AGENT_BAD_OUTPUT",
207
+ `${scriptName} returned unexpected shape: ${envelope.error.message}`,
208
+ { exitCode },
209
+ );
210
+ }
211
+ return envelope.data.data;
212
+ }
213
+
214
+ /**
215
+ * The agent may log info lines to stderr alongside the JSON envelope.
216
+ * On stdout we expect a single trailing JSON object; on stderr the
217
+ * envelope is the last line. This helper takes the last non-empty line
218
+ * and parses it as JSON.
219
+ */
220
+ function tryParseLastJsonLine(buf: string): unknown {
221
+ const trimmed = buf.trim();
222
+ if (!trimmed) return null;
223
+ const lines = trimmed.split(/\r?\n/);
224
+ for (let i = lines.length - 1; i >= 0; i--) {
225
+ const line = (lines[i] ?? "").trim();
226
+ if (!line) continue;
227
+ try {
228
+ return JSON.parse(line);
229
+ } catch {
230
+ // Keep walking back; agent log lines are not JSON.
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Public API. One function per agent command.
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export const agent = {
241
+ async taskUp(input: TaskLaunchInput): Promise<TaskUpResult> {
242
+ const commandDbPath = createTaskUpCommandDb(input);
243
+ // Materialize the creator's per-user SSH key so task-up.sh clones + pushes
244
+ // as them (ADR-029); null → task-up.sh falls back to the operator identity.
245
+ const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
246
+ try {
247
+ return await runAgent(
248
+ "task-up.sh",
249
+ [input.task.id],
250
+ TaskUpData,
251
+ commandDbPath,
252
+ identityDir ? { UAI_TASK_IDENTITY_DIR: identityDir } : {},
253
+ );
254
+ } finally {
255
+ removeCommandDb(commandDbPath);
256
+ removeTaskIdentity(input.task.id);
257
+ }
258
+ },
259
+
260
+ async taskDown(input: TaskDownInput): Promise<TaskDownResult> {
261
+ const commandDbPath = createTaskDownCommandDb(
262
+ input,
263
+ getHostTask(input.taskId),
264
+ );
265
+ try {
266
+ return await runAgent(
267
+ "task-down.sh",
268
+ [input.taskId],
269
+ TaskDownData,
270
+ commandDbPath,
271
+ );
272
+ } finally {
273
+ removeCommandDb(commandDbPath);
274
+ }
275
+ },
276
+
277
+ async taskStatus(taskId: string): Promise<TaskStatusResult> {
278
+ const commandDbPath = createTaskStatusCommandDb(taskId, getHostTask(taskId));
279
+ try {
280
+ return await runAgent(
281
+ "task-status.sh",
282
+ [taskId],
283
+ TaskStatusData,
284
+ commandDbPath,
285
+ );
286
+ } finally {
287
+ removeCommandDb(commandDbPath);
288
+ }
289
+ },
290
+ };
291
+
292
+ export type Agent = typeof agent;