@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,343 @@
1
+ /**
2
+ * ClaudeSession — a real AgentSession backed by the Claude Code CLI in
3
+ * stream-json mode, run inside the task container (ADR-010).
4
+ *
5
+ * docker exec -i task-<id>-app-1 \
6
+ * claude --print --input-format stream-json --output-format stream-json \
7
+ * --verbose --no-session-persistence
8
+ *
9
+ * The CLI is a persistent bidirectional process: uai writes one JSON
10
+ * line per user turn to stdin and reads a stream of JSON event lines
11
+ * from stdout. Same binary, same subscription auth as the TUI — not the
12
+ * paid API (docs/interaction-model.md).
13
+ *
14
+ * Protocol mapping lives in the pure `mapClaudeLine` function so it can
15
+ * be unit-tested against fixture lines without spawning anything.
16
+ *
17
+ * ⚠️ VERIFY-ON-MAC: the exact stream-json envelope — especially the
18
+ * permission/control-request shape — must be checked against the
19
+ * installed `claude` build. The mapping is isolated here precisely so
20
+ * those fixups stay in one place.
21
+ */
22
+
23
+ import { newId } from "../ulid";
24
+ import { dockerExecArgs, LineProcess } from "./proc";
25
+ import { register } from "./registry";
26
+ import type {
27
+ AgentEvent,
28
+ AgentEventHandler,
29
+ AgentKind,
30
+ AgentSession,
31
+ RosterAgent,
32
+ } from "./types";
33
+
34
+ // Published Claude model aliases the CLI accepts via `--model`. The CLI
35
+ // resolves these aliases to the current dated snapshots, so this list is
36
+ // stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
37
+ // retired alias). Order is display order in the cloud picker.
38
+ const CLAUDE_MODELS = ["opus", "sonnet", "haiku"];
39
+
40
+ // Opus ("opus" alias = Opus 4.8) is the default: the strongest coding model
41
+ // for the interactive loop. Update alongside CLAUDE_MODELS.
42
+ const CLAUDE_DEFAULT_MODEL = "opus";
43
+
44
+ // Reasoning levels passed through via `claude --effort <level>`. Taken from
45
+ // the CLI's `--help`: `--effort <level>` accepts (low, medium, high, xhigh,
46
+ // max). UPDATE WHEN THE CLI CHANGES its effort levels.
47
+ const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
48
+
49
+ // High is the default reasoning level. Update alongside CLAUDE_EFFORTS.
50
+ const CLAUDE_DEFAULT_EFFORT = "high";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Pure protocol mapping — stream-json line → AgentEvent[].
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Narrow an unknown to a plain object. */
57
+ function isObj(v: unknown): v is Record<string, unknown> {
58
+ return typeof v === "object" && v !== null;
59
+ }
60
+
61
+ /**
62
+ * Map one stream-json stdout line to zero or more AgentEvents.
63
+ *
64
+ * Contract:
65
+ * - `stream_event` content_block_delta(text) → `message_delta`
66
+ * - `assistant` message tool_use blocks → `tool_call`
67
+ * - `result` → `message_complete` + `turn_complete`
68
+ * - control/permission request → `permission_request`
69
+ * - anything else → [] (ignored)
70
+ *
71
+ * A malformed line yields []. The mapper is intentionally total and
72
+ * side-effect free.
73
+ */
74
+ export function mapClaudeLine(raw: string): AgentEvent[] {
75
+ let json: unknown;
76
+ try {
77
+ json = JSON.parse(raw);
78
+ } catch {
79
+ return [];
80
+ }
81
+ if (!isObj(json)) return [];
82
+
83
+ const type = json.type;
84
+
85
+ // --- streaming text deltas --------------------------------------------
86
+ if (type === "stream_event" && isObj(json.event)) {
87
+ const event = json.event;
88
+ if (
89
+ event.type === "content_block_delta" &&
90
+ isObj(event.delta) &&
91
+ event.delta.type === "text_delta" &&
92
+ typeof event.delta.text === "string"
93
+ ) {
94
+ return [{ type: "message_delta", text: event.delta.text }];
95
+ }
96
+ return [];
97
+ }
98
+
99
+ // --- full assistant message: harvest tool calls -----------------------
100
+ if (type === "assistant" && isObj(json.message)) {
101
+ const content = json.message.content;
102
+ if (!Array.isArray(content)) return [];
103
+ const out: AgentEvent[] = [];
104
+ for (const block of content) {
105
+ if (isObj(block) && block.type === "tool_use") {
106
+ const name = typeof block.name === "string" ? block.name : "tool";
107
+ out.push({
108
+ type: "tool_call",
109
+ id: typeof block.id === "string" ? block.id : newId(),
110
+ title: name,
111
+ detail: safeStringify(block.input),
112
+ });
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // --- turn result ------------------------------------------------------
119
+ if (type === "result") {
120
+ const text = typeof json.result === "string" ? json.result : "";
121
+ if (json.is_error === true) {
122
+ return [
123
+ { type: "error", message: text || "claude returned an error" },
124
+ { type: "turn_complete" },
125
+ ];
126
+ }
127
+ return [
128
+ { type: "message_complete", text },
129
+ { type: "turn_complete" },
130
+ ];
131
+ }
132
+
133
+ // --- permission / control request -------------------------------------
134
+ // ⚠️ VERIFY-ON-MAC: confirm this envelope against the real CLI.
135
+ if (type === "control_request" && isObj(json.request)) {
136
+ const req = json.request;
137
+ if (req.subtype === "can_use_tool") {
138
+ return [
139
+ {
140
+ type: "permission_request",
141
+ id: typeof json.request_id === "string" ? json.request_id : newId(),
142
+ title:
143
+ typeof req.tool_name === "string"
144
+ ? `Allow ${req.tool_name}?`
145
+ : "Permission requested",
146
+ detail: safeStringify(req.input),
147
+ },
148
+ ];
149
+ }
150
+ return [];
151
+ }
152
+
153
+ return [];
154
+ }
155
+
156
+ function safeStringify(v: unknown): string {
157
+ if (v === undefined) return "";
158
+ if (typeof v === "string") return v;
159
+ try {
160
+ return JSON.stringify(v, null, 2);
161
+ } catch {
162
+ return String(v);
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // The session.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const CLAUDE_ARGS = [
171
+ "--print",
172
+ "--input-format",
173
+ "stream-json",
174
+ "--output-format",
175
+ "stream-json",
176
+ "--verbose",
177
+ // uai owns durable chat persistence. Claude Code's JSON transcript
178
+ // files are an implementation detail and can contain redacted
179
+ // thinking blocks. Replaying those from disk has caused Anthropic API
180
+ // 400s when a later turn sees a changed thinking block, so keep each
181
+ // managed stream-json process in-memory only.
182
+ "--no-session-persistence",
183
+ // Full tool access, no per-call prompts. Safe here precisely because
184
+ // a uai task runs in a throwaway, isolated container operating on a
185
+ // disposable worktree (ADR-001 / ADR-010) — the container *is* the
186
+ // sandbox. Without this, stream-json has no interactive approver and
187
+ // every Write/Bash silently self-denies.
188
+ "--dangerously-skip-permissions",
189
+ ];
190
+
191
+ export class ClaudeSession implements AgentSession {
192
+ readonly agentId: string;
193
+ readonly kind: AgentKind = "claude";
194
+
195
+ private readonly proc: LineProcess;
196
+ private readonly handlers = new Set<AgentEventHandler>();
197
+ private closed = false;
198
+
199
+ constructor(args: {
200
+ agent: RosterAgent;
201
+ containerName: string;
202
+ systemPreamble: string;
203
+ }) {
204
+ this.agentId = args.agent.id;
205
+
206
+ // The uai channel briefing (how to @-mention, the agent roster, the
207
+ // project's defaultPrompt) is passed as a real system prompt via
208
+ // `--append-system-prompt`, so it applies to every turn — not
209
+ // smuggled into the first user message.
210
+ const cliArgs = [...CLAUDE_ARGS];
211
+ if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
212
+ cliArgs.push("--include-partial-messages");
213
+ }
214
+ // The agent's model (when set) selects which Claude model the CLI
215
+ // drives. Without it the CLI uses the account default.
216
+ if (args.agent.model) {
217
+ cliArgs.push("--model", args.agent.model);
218
+ }
219
+ // The agent's effort (when set) selects the CLI reasoning level. Without
220
+ // it the CLI uses its own default.
221
+ if (args.agent.effort) {
222
+ cliArgs.push("--effort", args.agent.effort);
223
+ }
224
+ if (args.systemPreamble.trim().length > 0) {
225
+ cliArgs.push("--append-system-prompt", args.systemPreamble);
226
+ }
227
+ // Forward host-resident Claude auth into the headless exec. Headless
228
+ // `--print` does NOT use the interactive subscription/keychain path, so
229
+ // it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
230
+ // API key. These live in the host-agent's env (never the cloud, ADR-015);
231
+ // only ones actually set are forwarded.
232
+ const { command, args: argv } = dockerExecArgs(
233
+ args.containerName,
234
+ "claude",
235
+ cliArgs,
236
+ ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
237
+ );
238
+ this.proc = new LineProcess({
239
+ command,
240
+ args: argv,
241
+ debugLabel: `claude:${this.agentId}`,
242
+ });
243
+ this.proc.onLine((line) => {
244
+ for (const event of mapClaudeLine(line)) this.emit(event);
245
+ });
246
+ this.proc.onExit((code) => {
247
+ if (this.closed) return;
248
+ const tail = this.proc.stderrTail.trim();
249
+ // Surface an error event when:
250
+ // - the process exited non-zero, OR
251
+ // - it exited 0 but stderr says "Claude configuration file not
252
+ // found". That message means Claude silently bailed because
253
+ // its config was unlinked (Docker Desktop macOS atomic-write
254
+ // race). The orchestrator handles this by repairing the
255
+ // config and respawning, but only if it sees the error event.
256
+ const configMissing =
257
+ /Claude configuration file not found/i.test(tail);
258
+ if (code !== 0 || configMissing) {
259
+ this.emit({
260
+ type: "error",
261
+ message:
262
+ `claude process exited (${code ?? "spawn failed"})` +
263
+ (tail
264
+ ? `:\n${tail}`
265
+ : " — no stderr. Is the claude CLI installed in the task container, and is the container running?"),
266
+ });
267
+ }
268
+ this.closed = true;
269
+ this.emit({ type: "exit", code: code ?? -1 });
270
+ });
271
+ }
272
+
273
+ onEvent(handler: AgentEventHandler): () => void {
274
+ this.handlers.add(handler);
275
+ return () => this.handlers.delete(handler);
276
+ }
277
+
278
+ private emit(event: AgentEvent): void {
279
+ if (this.closed && event.type !== "exit") return;
280
+ for (const h of this.handlers) h(event);
281
+ }
282
+
283
+ async send(text: string): Promise<void> {
284
+ if (this.closed) return;
285
+ this.proc.writeLine({
286
+ type: "user",
287
+ message: { role: "user", content: text },
288
+ });
289
+ }
290
+
291
+ async interrupt(): Promise<void> {
292
+ if (this.closed) return;
293
+ // Claude Code's stream-json control channel: an `interrupt` control-request
294
+ // stops the in-flight turn (the SDK's ESC). Safe to send when idle — the
295
+ // CLI acks with a control_response we don't need to track.
296
+ // ⚠️ VERIFY-ON-MAC: confirm the control-request `interrupt` envelope.
297
+ this.proc.writeLine({
298
+ type: "control_request",
299
+ request_id: newId(),
300
+ request: { subtype: "interrupt" },
301
+ });
302
+ }
303
+
304
+ async resolvePermission(
305
+ requestId: string,
306
+ decision: "accept" | "decline",
307
+ ): Promise<void> {
308
+ if (this.closed) return;
309
+ // ⚠️ VERIFY-ON-MAC: confirm the control-response envelope.
310
+ this.proc.writeLine({
311
+ type: "control_response",
312
+ request_id: requestId,
313
+ response: {
314
+ subtype: "can_use_tool",
315
+ behavior: decision === "accept" ? "allow" : "deny",
316
+ },
317
+ });
318
+ }
319
+
320
+ async close(): Promise<void> {
321
+ if (this.closed) {
322
+ await this.proc.close();
323
+ return;
324
+ }
325
+ this.closed = true;
326
+ await this.proc.close();
327
+ this.emit({ type: "exit", code: 0 });
328
+ this.handlers.clear();
329
+ }
330
+ }
331
+
332
+ // Register the Claude adapter at module load (ADR-021). The `--model`
333
+ // pass-through above means `model` flows from the roster entry to the CLI.
334
+ register({
335
+ kind: "claude",
336
+ label: "Claude",
337
+ supportedModels: () => [...CLAUDE_MODELS],
338
+ defaultModel: CLAUDE_DEFAULT_MODEL,
339
+ supportedEfforts: () => [...CLAUDE_EFFORTS],
340
+ defaultEffort: CLAUDE_DEFAULT_EFFORT,
341
+ create: async ({ agent, containerName, systemPreamble }) =>
342
+ new ClaudeSession({ agent, containerName, systemPreamble }),
343
+ });