@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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/uai-host.mjs +14 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/meta/_journal.json +41 -0
- package/db/schema.ts +82 -0
- package/images/standard/Dockerfile +232 -0
- package/images/standard/README.md +122 -0
- package/images/standard/container/code-server-settings.json +36 -0
- package/images/standard/container/uai-init +215 -0
- package/images/standard/tool-versions +2 -0
- package/lib/agent.ts +292 -0
- package/lib/agents/claude.ts +343 -0
- package/lib/agents/codex.ts +522 -0
- package/lib/agents/factory.ts +34 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/proc.ts +172 -0
- package/lib/agents/registry.ts +109 -0
- package/lib/agents/types.ts +133 -0
- package/lib/attachments.ts +46 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/command-db.ts +278 -0
- package/lib/db.ts +68 -0
- package/lib/env.ts +140 -0
- package/lib/git-diff.ts +370 -0
- package/lib/git-identity.ts +65 -0
- package/lib/github-tokens.ts +321 -0
- package/lib/orchestrator.ts +975 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/repo-clone.ts +127 -0
- package/lib/runtime-state.ts +120 -0
- package/lib/secrets.ts +71 -0
- package/lib/ssh.ts +186 -0
- package/lib/standard-image.ts +152 -0
- package/lib/task-diff.ts +113 -0
- package/lib/task-status.ts +46 -0
- package/lib/transcript.ts +30 -0
- package/lib/ulid.ts +7 -0
- package/package.json +85 -0
- package/scripts/agent/_common.sh +248 -0
- package/scripts/agent/task-down.sh +113 -0
- package/scripts/agent/task-status.sh +54 -0
- package/scripts/agent/task-up.sh +457 -0
- package/scripts/install/darwin.ts +167 -0
- package/scripts/install/linux.ts +115 -0
- package/scripts/install/types.ts +35 -0
- package/scripts/install/util.ts +39 -0
- package/scripts/install/win.ts +130 -0
- package/src/cli.ts +445 -0
- package/src/index.ts +375 -0
- package/src/load-env.ts +52 -0
- package/src/main.ts +1156 -0
- package/src/paths.ts +64 -0
- package/src/protocol.ts +413 -0
- package/src/ui/server.ts +343 -0
- package/src/ui/types.ts +78 -0
- package/ui/app.js +264 -0
- package/ui/index.html +55 -0
- package/ui/style.css +359 -0
- 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
|
+
});
|