@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
package/src/paths.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared filesystem paths for the host service + local UI (ADR-028).
3
+ * Used by main.ts (server boot), cli.ts (status/open/logs), and the per-OS
4
+ * install modules so the log/port/asset locations have a single definition.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { env } from "../lib/env";
13
+
14
+ const srcDir = dirname(fileURLToPath(import.meta.url)); // host-agent/src
15
+
16
+ /** Base dir for host state — the repo root in a checkout, ~/.uai when
17
+ * installed via npm. Used as the service working directory. */
18
+ export function uaiHome(): string {
19
+ return env.uaiHome;
20
+ }
21
+
22
+ /** `.env.local` under UAI_HOME — where `setup`/`pair` persist the credential. */
23
+ export function envLocalPath(): string {
24
+ return join(env.uaiHome, ".env.local");
25
+ }
26
+
27
+ /** Static UI asset dir (`host-agent/ui`). */
28
+ export function uiAssetsDir(): string {
29
+ return resolve(srcDir, "..", "ui");
30
+ }
31
+
32
+ /** Where the chosen UI port is persisted (`$UAI_DATA_DIR/host.port`). */
33
+ export function uiPortFilePath(): string {
34
+ return join(env.dataDir, "host.port");
35
+ }
36
+
37
+ /** Per-OS service log path — informational in `/api/status`, the file
38
+ * `uai-host logs` tails. Linux logs to journald, so returns a hint there. */
39
+ export function serviceLogPath(): string {
40
+ if (process.platform === "darwin") {
41
+ return resolve(homedir(), "Library", "Logs", "Uai", "host.log");
42
+ }
43
+ if (process.platform === "win32") {
44
+ return join(
45
+ process.env.LOCALAPPDATA ?? homedir(),
46
+ "Uai",
47
+ "service",
48
+ "logs",
49
+ "host.log",
50
+ );
51
+ }
52
+ return "journald (journalctl --user -u uai-host)";
53
+ }
54
+
55
+ /** `@runuai/host` package version (read at runtime; no JSON-import dance). */
56
+ export function packageVersion(): string {
57
+ try {
58
+ const raw = readFileSync(resolve(srcDir, "..", "package.json"), "utf8");
59
+ const pkg = JSON.parse(raw) as { version?: string };
60
+ return pkg.version ?? "0.0.0";
61
+ } catch {
62
+ return "0.0.0";
63
+ }
64
+ }
@@ -0,0 +1,413 @@
1
+ export enum HostErrorCode {
2
+ TaskNotFound = "task_not_found",
3
+ ProjectNotFound = "project_not_found",
4
+ TemplateNotFound = "template_not_found",
5
+ TaskAlreadyRunning = "task_already_running",
6
+ TaskNotRunning = "task_not_running",
7
+ ContainerInitFailed = "container_init_failed",
8
+ ComposeUpFailed = "compose_up_failed",
9
+ ComposeDownFailed = "compose_down_failed",
10
+ WorktreeFailed = "worktree_failed",
11
+ CloneFailed = "clone_failed",
12
+ FetchFailed = "fetch_failed",
13
+ RenderFailed = "render_failed",
14
+ DbFailed = "db_failed",
15
+ NoSuchAgent = "no_such_agent",
16
+ NoSuchPermission = "no_such_permission",
17
+ HostUnavailable = "host_unavailable",
18
+ Internal = "internal",
19
+ }
20
+
21
+ export type HostCommandResult<T> =
22
+ | { ok: true; value: T }
23
+ | {
24
+ ok: false;
25
+ code: HostErrorCode;
26
+ message: string;
27
+ retryable?: boolean;
28
+ };
29
+
30
+ export interface CommandContext {
31
+ commandId: string;
32
+ }
33
+
34
+ export type PermissionDecision = { kind: "accept" } | { kind: "decline" };
35
+
36
+ /**
37
+ * An agent materialised onto a task (ADR-021/ADR-022). `kind` is the
38
+ * host-advertised agent kind ("claude" | "codex" | "gemini" | …); there is no
39
+ * `role` field. `model` is passed through to the CLI by the adapter. Each
40
+ * agent carries its own first-turn `initialPrompt` and persona `defaultPrompt`.
41
+ * `effort` (when set) is the CLI reasoning level, passed through like `model`.
42
+ */
43
+ export interface TaskAgent {
44
+ id: string;
45
+ label: string;
46
+ kind: string;
47
+ model?: string;
48
+ effort?: string;
49
+ defaultPrompt?: string;
50
+ initialPrompt?: string;
51
+ }
52
+
53
+ /**
54
+ * Host capability advertisement (ADR-021). Sent as a `host.capabilities`
55
+ * frame after auth-success and re-sent when the host's adapter registry
56
+ * changes. The cloud caches it per hostId to drive the task-creation pickers.
57
+ */
58
+ export interface HostCapabilities {
59
+ agentKinds: Array<{
60
+ kind: string;
61
+ label: string;
62
+ supportedModels: string[];
63
+ defaultModel?: string;
64
+ supportedEfforts: string[];
65
+ defaultEffort?: string;
66
+ }>;
67
+ runtimes: Array<{
68
+ kind: string;
69
+ availableVersions: string[];
70
+ }>;
71
+ }
72
+
73
+ export interface TaskUpResult {
74
+ composeProject: string;
75
+ worktreePath: string;
76
+ codeServerPort?: number;
77
+ previewPorts?: Array<{ name: string; hostPort: number }>;
78
+ }
79
+
80
+ export interface TaskDownResult {
81
+ status?: string;
82
+ alreadyGone?: boolean;
83
+ [key: string]: unknown;
84
+ }
85
+
86
+ export interface TaskStatusResult {
87
+ composeRunning: boolean;
88
+ containers: string[];
89
+ worktreePresent: boolean;
90
+ }
91
+
92
+ export interface CloneRepoInput {
93
+ url: string;
94
+ projectId: string;
95
+ }
96
+
97
+ export interface CloneRepoResult {
98
+ status: "ready" | "error";
99
+ error?: string;
100
+ absolutePath: string;
101
+ }
102
+
103
+ export interface TaskCommandTask {
104
+ id: string;
105
+ ownerUserId: string;
106
+ hostId: string;
107
+ name: string;
108
+ slug: string;
109
+ branch: string;
110
+ status: string;
111
+ globalContext?: string;
112
+ reviewerOrder?: string[];
113
+ agents: TaskAgent[];
114
+ }
115
+
116
+ /**
117
+ * A project snapshot carried in a task command. A project is a single repo
118
+ * plus config (ADR-022). `previewPorts` and `env` are JSON strings (the
119
+ * host parses them). `position` orders the clone layout and prompt concat.
120
+ */
121
+ export interface TaskCommandProject {
122
+ id: string;
123
+ ownerUserId: string;
124
+ name: string;
125
+ slug: string;
126
+ repoUrl: string;
127
+ defaultPrompt: string;
128
+ previewPorts: string;
129
+ env: string;
130
+ toolVersions?: string;
131
+ extra?: string;
132
+ position: number;
133
+ }
134
+
135
+ export interface TaskLaunchInput {
136
+ task: TaskCommandTask;
137
+ projects: TaskCommandProject[];
138
+ ownerEmail?: string;
139
+ ownerName?: string;
140
+ }
141
+
142
+ export interface TaskDownInput {
143
+ taskId: string;
144
+ task: TaskCommandTask;
145
+ projects: TaskCommandProject[];
146
+ }
147
+
148
+ export interface TaskDiffInput {
149
+ taskId: string;
150
+ projects: Array<{ id: string; slug: string }>;
151
+ }
152
+
153
+ export interface ChannelEnsureInput {
154
+ taskId: string;
155
+ agents: TaskAgent[];
156
+ globalContext?: string;
157
+ projects: Array<{ slug: string; defaultPrompt: string }>;
158
+ branch: string;
159
+ workspacePath: string;
160
+ status: string;
161
+ }
162
+
163
+ export type DiffStatus =
164
+ | "added"
165
+ | "deleted"
166
+ | "modified"
167
+ | "renamed"
168
+ | "copied"
169
+ | "binary";
170
+
171
+ export interface DiffLine {
172
+ kind: "ctx" | "add" | "del";
173
+ content: string;
174
+ oldNo?: number;
175
+ newNo?: number;
176
+ }
177
+
178
+ export interface DiffHunk {
179
+ oldStart: number;
180
+ oldLines: number;
181
+ newStart: number;
182
+ newLines: number;
183
+ header: string;
184
+ lines: DiffLine[];
185
+ }
186
+
187
+ export interface DiffFile {
188
+ path: string;
189
+ oldPath?: string;
190
+ status: DiffStatus;
191
+ language?: string;
192
+ binary: boolean;
193
+ hunks: DiffHunk[];
194
+ }
195
+
196
+ export interface TaskDiffRepo {
197
+ id: string;
198
+ name: string;
199
+ mountPath: string;
200
+ defaultBranch: string;
201
+ files: DiffFile[];
202
+ error?: string;
203
+ }
204
+
205
+ export interface TaskDiffResult {
206
+ repos: TaskDiffRepo[];
207
+ }
208
+
209
+ export interface HostCommands {
210
+ taskUp(
211
+ ctx: CommandContext,
212
+ input: TaskLaunchInput,
213
+ ): Promise<HostCommandResult<TaskUpResult>>;
214
+ taskDown(
215
+ ctx: CommandContext,
216
+ input: TaskDownInput,
217
+ ): Promise<HostCommandResult<TaskDownResult>>;
218
+ taskStatus(
219
+ ctx: CommandContext,
220
+ taskId: string,
221
+ ): Promise<HostCommandResult<TaskStatusResult>>;
222
+ channelEnsure(
223
+ ctx: CommandContext,
224
+ input: ChannelEnsureInput,
225
+ ): Promise<HostCommandResult<void>>;
226
+ channelTeardown(
227
+ ctx: CommandContext,
228
+ taskId: string,
229
+ ): Promise<HostCommandResult<void>>;
230
+ channelDeliver(
231
+ ctx: CommandContext,
232
+ taskId: string,
233
+ agentId: string,
234
+ text: string,
235
+ ): Promise<HostCommandResult<void>>;
236
+ channelResolvePermission(
237
+ ctx: CommandContext,
238
+ taskId: string,
239
+ agentId: string,
240
+ requestId: string,
241
+ decision: PermissionDecision,
242
+ ): Promise<HostCommandResult<void>>;
243
+ cloneRepo(
244
+ ctx: CommandContext,
245
+ input: CloneRepoInput,
246
+ ): Promise<HostCommandResult<CloneRepoResult>>;
247
+ taskDiff(
248
+ ctx: CommandContext,
249
+ input: TaskDiffInput,
250
+ ): Promise<HostCommandResult<TaskDiffResult>>;
251
+ // Chat attachments live in the task workspace on the host (ADR-015); the
252
+ // cloud relays bytes (base64) in and out, never persisting them.
253
+ attachmentWrite(
254
+ ctx: CommandContext,
255
+ input: { taskId: string; filename: string; dataBase64: string },
256
+ ): Promise<HostCommandResult<void>>;
257
+ attachmentRead(
258
+ ctx: CommandContext,
259
+ input: { taskId: string; filename: string },
260
+ ): Promise<HostCommandResult<{ dataBase64: string }>>;
261
+ channelInterrupt(
262
+ ctx: CommandContext,
263
+ taskId: string,
264
+ agentId: string,
265
+ ): Promise<HostCommandResult<void>>;
266
+ // Append a chat message to the task's shared transcript (<workspace>/.uai/
267
+ // chat.md) so agents can read cross-agent context on demand.
268
+ appendTranscript(
269
+ ctx: CommandContext,
270
+ taskId: string,
271
+ author: string,
272
+ text: string,
273
+ ): Promise<HostCommandResult<void>>;
274
+ }
275
+
276
+ export interface TunnelReqLine {
277
+ method: string;
278
+ url: string;
279
+ headers: [string, string][];
280
+ }
281
+
282
+ export type TunnelTarget = "editor" | "preview";
283
+
284
+ export type CloudToHost =
285
+ | {
286
+ kind: "command";
287
+ commandId: string;
288
+ command: keyof HostCommands;
289
+ args: unknown[];
290
+ }
291
+ | { kind: "pong"; ts: number }
292
+ | {
293
+ kind: "tunnel.open";
294
+ tunnelId: string;
295
+ target: TunnelTarget;
296
+ taskId: string;
297
+ name?: string;
298
+ reqLine: TunnelReqLine;
299
+ upgrade: boolean;
300
+ }
301
+ | { kind: "tunnel.data"; tunnelId: string }
302
+ // Signals the request body is complete so the host can end the upstream
303
+ // request (only then does an upstream that reads the body reply). Without
304
+ // this, a body request deadlocks: the host never ends the upstream, so it
305
+ // never responds, so the cloud never sends an ack. (HTTP tunnels only.)
306
+ | { kind: "tunnel.requestEnd"; tunnelId: string }
307
+ | { kind: "tunnel.close"; tunnelId: string; reason?: string }
308
+ // GitHub App connect lifecycle (ADR-027). The cloud forwards the user's
309
+ // refresh token to the host, which encrypts + persists it; the per-task
310
+ // access-token exchange is a host→cloud HTTP POST, not a frame.
311
+ | {
312
+ kind: "gh.connect.set";
313
+ userId: string;
314
+ installationId: number;
315
+ githubLogin: string;
316
+ targetType: "User" | "Organization";
317
+ refreshToken: string;
318
+ refreshTokenExpiresAt?: number;
319
+ }
320
+ | { kind: "gh.connect.clear"; userId: string }
321
+ // Per-user SSH key lifecycle (ADR-029). Keys are generated + stored on the
322
+ // host; only the public key crosses the bridge (ssh.key.ack). `get` fetches
323
+ // without creating, `ensure` creates-if-absent, `delete` removes.
324
+ | { kind: "ssh.key.get"; userId: string }
325
+ | { kind: "ssh.key.ensure"; userId: string }
326
+ | { kind: "ssh.key.delete"; userId: string };
327
+
328
+ export type HostToCloud =
329
+ | { kind: "auth"; token: string; hostId: string }
330
+ | { kind: "host.capabilities"; capabilities: HostCapabilities }
331
+ | {
332
+ kind: "result";
333
+ commandId: string;
334
+ result: HostCommandResult<unknown>;
335
+ }
336
+ | { kind: "event"; event: HostEvent }
337
+ | { kind: "ping"; ts: number }
338
+ | {
339
+ kind: "tunnel.ack";
340
+ tunnelId: string;
341
+ ok: boolean;
342
+ status?: number;
343
+ statusText?: string;
344
+ headers?: [string, string][];
345
+ message?: string;
346
+ }
347
+ | { kind: "tunnel.data"; tunnelId: string }
348
+ | { kind: "tunnel.close"; tunnelId: string; reason?: string }
349
+ // Ack for gh.connect.set / gh.connect.clear (ADR-027).
350
+ | { kind: "gh.connect.ack"; userId: string; ok: true }
351
+ | { kind: "gh.connect.ack"; userId: string; ok: false; error: string }
352
+ // Ack for ssh.key.get / ssh.key.ensure / ssh.key.delete (ADR-029). publicKey
353
+ // is null when the user has no key (after delete, or a get-miss).
354
+ | { kind: "ssh.key.ack"; userId: string; ok: true; publicKey: string | null }
355
+ | { kind: "ssh.key.ack"; userId: string; ok: false; error: string };
356
+
357
+ export type HostEvent =
358
+ | {
359
+ kind: "task.status_changed";
360
+ taskId: string;
361
+ from: string;
362
+ to: string;
363
+ }
364
+ | {
365
+ kind: "task.log";
366
+ taskId: string;
367
+ stream: "stdout" | "stderr";
368
+ chunk: string;
369
+ }
370
+ | {
371
+ kind: "agent.message_delta";
372
+ taskId: string;
373
+ agentId: string;
374
+ chunk: string;
375
+ }
376
+ | {
377
+ kind: "agent.message_complete";
378
+ taskId: string;
379
+ agentId: string;
380
+ fullText: string;
381
+ mentions: string[];
382
+ }
383
+ | {
384
+ kind: "agent.tool_call";
385
+ taskId: string;
386
+ agentId: string;
387
+ tool: string;
388
+ meta: unknown;
389
+ }
390
+ | {
391
+ kind: "agent.permission_request";
392
+ taskId: string;
393
+ agentId: string;
394
+ requestId: string;
395
+ meta: unknown;
396
+ }
397
+ | {
398
+ kind: "agent.exit";
399
+ taskId: string;
400
+ agentId: string;
401
+ reason: string;
402
+ }
403
+ // A host-originated system note for the task channel (ADR-027) — e.g. gh
404
+ // auth expired. The cloud renders it as an author:"system" message.
405
+ | {
406
+ kind: "system.note";
407
+ taskId: string;
408
+ text: string;
409
+ };
410
+
411
+ export interface HostEventStream {
412
+ subscribe(handler: (e: HostEvent) => void): () => void;
413
+ }