@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/index.ts ADDED
@@ -0,0 +1,375 @@
1
+ import { agent, AgentError } from "../lib/agent";
2
+ import { cloneRepo } from "../lib/repo-clone";
3
+ import { getOrchestrator } from "../lib/orchestrator";
4
+ import { setupTaskGithub, clearRefresh } from "../lib/github-tokens";
5
+ import { readAttachment, writeAttachment } from "../lib/attachments";
6
+ import { appendTranscript as writeTranscript } from "../lib/transcript";
7
+ import { buildTaskDiff } from "../lib/task-diff";
8
+ import {
9
+ recordHostEvent,
10
+ recordTaskDown,
11
+ recordTaskError,
12
+ recordTaskOwner,
13
+ recordTaskStarting,
14
+ recordTaskUpResult,
15
+ } from "../lib/runtime-state";
16
+ import {
17
+ HostErrorCode,
18
+ type CommandContext,
19
+ type ChannelEnsureInput,
20
+ type HostCommandResult,
21
+ type HostCommands,
22
+ type HostEventStream,
23
+ type PermissionDecision,
24
+ type TaskDiffInput,
25
+ type TaskDownInput,
26
+ type TaskLaunchInput,
27
+ } from "./protocol";
28
+
29
+ export type {
30
+ TaskDownResult,
31
+ TaskStatusResult,
32
+ TaskUpResult,
33
+ } from "../lib/agent";
34
+
35
+ export {
36
+ parseMentions,
37
+ resolveAddressing,
38
+ buildSystemPreamble,
39
+ } from "../lib/orchestrator";
40
+
41
+ export {
42
+ AgentKind,
43
+ Roster,
44
+ RosterAgent,
45
+ parseRoster,
46
+ } from "../lib/agents/types";
47
+ export type {
48
+ AgentEvent,
49
+ AgentEventHandler,
50
+ AgentSession,
51
+ AgentSessionFactory,
52
+ } from "../lib/agents/types";
53
+
54
+ export {
55
+ capabilities as agentCapabilities,
56
+ factoryFor as agentFactoryFor,
57
+ list as listAgentAdapters,
58
+ register as registerAgentAdapter,
59
+ } from "../lib/agents/registry";
60
+ export type {
61
+ AgentKindCapability,
62
+ RegisteredAdapter,
63
+ } from "../lib/agents/registry";
64
+
65
+ export {
66
+ ASDF_DATA_VOLUME,
67
+ STANDARD_IMAGE_TAG,
68
+ STANDARD_RUNTIMES,
69
+ ensureStandardImage,
70
+ standardRuntimes,
71
+ } from "../lib/standard-image";
72
+
73
+ export { cloneRepo } from "../lib/repo-clone";
74
+ export type { CloneRepoInput, CloneRepoResult } from "../lib/repo-clone";
75
+
76
+ export { parseGitDiff, runGitDiff } from "../lib/git-diff";
77
+ export type { DiffFile, DiffHunk, DiffLine, DiffStatus } from "../lib/git-diff";
78
+
79
+ export {
80
+ PREVIEW_PORT_NAME_RE,
81
+ PreviewPortDeclarationSchema,
82
+ PreviewPortDeclarationsSchema,
83
+ PreviewPortRuntimeSchema,
84
+ PreviewPortRuntimesSchema,
85
+ parsePreviewPortDeclarations,
86
+ parsePreviewPortRuntimes,
87
+ } from "../lib/preview-ports";
88
+ export type {
89
+ PreviewPortDeclaration,
90
+ PreviewPortRuntime,
91
+ } from "../lib/preview-ports";
92
+
93
+ export const hostEvents: HostEventStream = {
94
+ subscribe(handler) {
95
+ return getOrchestrator().subscribeHostEvents(handler);
96
+ },
97
+ };
98
+
99
+ export const hostCommands: HostCommands = {
100
+ async taskUp(ctx, input) {
101
+ logCommand(ctx, "taskUp", input.task.id);
102
+ recordTaskStarting(input.task.id);
103
+ recordTaskOwner(
104
+ input.task.id,
105
+ input.task.ownerUserId,
106
+ input.ownerEmail ?? null,
107
+ input.ownerName ?? null,
108
+ input.projects.map((p) => p.slug),
109
+ );
110
+ recordHostEvent(input.task.id, "task.created");
111
+ const result = await wrapAgent(ctx, "taskUp", () => agent.taskUp(input));
112
+ if (result.ok) {
113
+ recordTaskUpResult(input.task.id, result.value);
114
+ recordHostEvent(input.task.id, "task.started");
115
+ // GitHub auth for the container is best-effort (ADR-027) and runs in the
116
+ // background — it must never block or fail task-up. Awaiting it here would
117
+ // couple the command result to a network token-exchange: a slow/hung
118
+ // exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
119
+ // and mark a running task as errored. setupTaskGithub injects + schedules
120
+ // (or emits a system note on failure) on its own; the agents come up
121
+ // regardless and the token lands well before the first `gh` call.
122
+ void setupTaskGithub(input.task.id, input.task.ownerUserId);
123
+ } else {
124
+ recordTaskError(input.task.id);
125
+ }
126
+ return result;
127
+ },
128
+
129
+ async taskDown(ctx, input) {
130
+ logCommand(ctx, "taskDown", input.taskId);
131
+ const result = await wrapAgent(ctx, "taskDown", () => agent.taskDown(input));
132
+ if (result.ok) {
133
+ recordTaskDown(input.taskId, result.value.status);
134
+ recordHostEvent(input.taskId, "task.ended");
135
+ clearRefresh(input.taskId);
136
+ }
137
+ return result;
138
+ },
139
+
140
+ async taskStatus(ctx, taskId) {
141
+ logCommand(ctx, "taskStatus", taskId);
142
+ return wrapAgent(ctx, "taskStatus", () => agent.taskStatus(taskId));
143
+ },
144
+
145
+ async channelEnsure(ctx, input) {
146
+ logCommand(ctx, "channelEnsure", input.taskId);
147
+ try {
148
+ getOrchestrator().registerChannelSpec(normalizeChannelSpec(input));
149
+ await getOrchestrator().ensureStarted(input.taskId);
150
+ return ok(undefined);
151
+ } catch (err) {
152
+ return failFromUnknown(err);
153
+ }
154
+ },
155
+
156
+ async channelTeardown(ctx, taskId) {
157
+ logCommand(ctx, "channelTeardown", taskId);
158
+ try {
159
+ await getOrchestrator().closeChannel(taskId);
160
+ return ok(undefined);
161
+ } catch (err) {
162
+ return failFromUnknown(err);
163
+ }
164
+ },
165
+
166
+ async channelDeliver(ctx, taskId, agentId, text) {
167
+ logCommand(ctx, "channelDeliver", taskId, agentId);
168
+ try {
169
+ const result = await getOrchestrator().deliver(taskId, agentId, text);
170
+ if (!result.ok) {
171
+ return {
172
+ ok: false,
173
+ code: mapDeliverError(result.error),
174
+ message: result.error,
175
+ };
176
+ }
177
+ return ok(undefined);
178
+ } catch (err) {
179
+ return failFromUnknown(err);
180
+ }
181
+ },
182
+
183
+ async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
184
+ logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
185
+ try {
186
+ const okResolved = await getOrchestrator().resolvePermission(
187
+ taskId,
188
+ agentId,
189
+ requestId,
190
+ toLegacyDecision(decision),
191
+ );
192
+ if (!okResolved) {
193
+ return {
194
+ ok: false,
195
+ code: HostErrorCode.NoSuchPermission,
196
+ message: "no live session for that permission request",
197
+ };
198
+ }
199
+ return ok(undefined);
200
+ } catch (err) {
201
+ return failFromUnknown(err);
202
+ }
203
+ },
204
+
205
+ async cloneRepo(ctx, input) {
206
+ logCommand(ctx, "cloneRepo", input.projectId);
207
+ try {
208
+ return ok(cloneRepo(input));
209
+ } catch (err) {
210
+ return failFromUnknown(err);
211
+ }
212
+ },
213
+
214
+ async taskDiff(ctx, input) {
215
+ logCommand(ctx, "taskDiff", input.taskId);
216
+ try {
217
+ return ok(await buildTaskDiff(input));
218
+ } catch (err) {
219
+ return failFromUnknown(err);
220
+ }
221
+ },
222
+
223
+ async attachmentWrite(ctx, input) {
224
+ logCommand(ctx, "attachmentWrite", input.taskId, input.filename);
225
+ try {
226
+ writeAttachment(
227
+ input.taskId,
228
+ input.filename,
229
+ Buffer.from(input.dataBase64, "base64"),
230
+ );
231
+ return ok(undefined);
232
+ } catch (err) {
233
+ return failFromUnknown(err);
234
+ }
235
+ },
236
+
237
+ async attachmentRead(ctx, input) {
238
+ logCommand(ctx, "attachmentRead", input.taskId, input.filename);
239
+ try {
240
+ const bytes = readAttachment(input.taskId, input.filename);
241
+ if (!bytes) {
242
+ return {
243
+ ok: false,
244
+ code: HostErrorCode.TaskNotFound,
245
+ message: `no such attachment: ${input.filename}`,
246
+ };
247
+ }
248
+ return ok({ dataBase64: bytes.toString("base64") });
249
+ } catch (err) {
250
+ return failFromUnknown(err);
251
+ }
252
+ },
253
+
254
+ async channelInterrupt(ctx, taskId, agentId) {
255
+ logCommand(ctx, "channelInterrupt", taskId, agentId);
256
+ try {
257
+ const result = await getOrchestrator().interrupt(taskId, agentId);
258
+ if (!result.ok) {
259
+ return {
260
+ ok: false,
261
+ code: HostErrorCode.TaskNotRunning,
262
+ message: result.error,
263
+ };
264
+ }
265
+ return ok(undefined);
266
+ } catch (err) {
267
+ return failFromUnknown(err);
268
+ }
269
+ },
270
+
271
+ async appendTranscript(_ctx, taskId, author, text) {
272
+ // Per-message + high-frequency, so no logCommand (avoid log spam).
273
+ try {
274
+ writeTranscript(taskId, author, text);
275
+ return ok(undefined);
276
+ } catch (err) {
277
+ return failFromUnknown(err);
278
+ }
279
+ },
280
+ };
281
+
282
+ function normalizeChannelSpec(input: ChannelEnsureInput): ChannelEnsureInput {
283
+ return { ...input, workspacePath: "/workspace" };
284
+ }
285
+
286
+ function logCommand(
287
+ ctx: CommandContext,
288
+ command: keyof HostCommands,
289
+ ...parts: string[]
290
+ ): void {
291
+ console.log(`[host-agent] ${ctx.commandId} ${command} ${parts.join(" ")}`);
292
+ }
293
+
294
+ async function wrapAgent<T>(
295
+ _ctx: CommandContext,
296
+ _command: keyof HostCommands,
297
+ fn: () => Promise<T>,
298
+ ): Promise<HostCommandResult<T>> {
299
+ try {
300
+ return ok(await fn());
301
+ } catch (err) {
302
+ return failFromUnknown(err);
303
+ }
304
+ }
305
+
306
+ function ok<T>(value: T): HostCommandResult<T> {
307
+ return { ok: true, value };
308
+ }
309
+
310
+ function failFromUnknown<T = never>(err: unknown): HostCommandResult<T> {
311
+ if (AgentError.is(err)) {
312
+ return {
313
+ ok: false,
314
+ code: mapAgentError(err.code),
315
+ message: err.message,
316
+ retryable: isRetryableAgentError(err.code),
317
+ };
318
+ }
319
+ return {
320
+ ok: false,
321
+ code: HostErrorCode.Internal,
322
+ message: err instanceof Error ? err.message : String(err),
323
+ };
324
+ }
325
+
326
+ function mapAgentError(code: string): HostErrorCode {
327
+ switch (code) {
328
+ case "TASK_NOT_FOUND":
329
+ return HostErrorCode.TaskNotFound;
330
+ case "PROJECT_NOT_FOUND":
331
+ return HostErrorCode.ProjectNotFound;
332
+ case "TEMPLATE_NOT_FOUND":
333
+ return HostErrorCode.TemplateNotFound;
334
+ case "COMPOSE_UP_FAILED":
335
+ return HostErrorCode.ComposeUpFailed;
336
+ case "COMPOSE_DOWN_FAILED":
337
+ return HostErrorCode.ComposeDownFailed;
338
+ case "WORKTREE_FAILED":
339
+ return HostErrorCode.WorktreeFailed;
340
+ case "CLONE_FAILED":
341
+ return HostErrorCode.CloneFailed;
342
+ case "FETCH_FAILED":
343
+ return HostErrorCode.FetchFailed;
344
+ case "RENDER_FAILED":
345
+ return HostErrorCode.RenderFailed;
346
+ case "CONTAINER_INIT_FAILED":
347
+ return HostErrorCode.ContainerInitFailed;
348
+ case "DB_FAILED":
349
+ return HostErrorCode.DbFailed;
350
+ default:
351
+ return HostErrorCode.Internal;
352
+ }
353
+ }
354
+
355
+ function mapDeliverError(message: string): HostErrorCode {
356
+ if (message.includes("not found")) return HostErrorCode.TaskNotFound;
357
+ if (message.includes("not running")) return HostErrorCode.TaskNotRunning;
358
+ if (message.includes("no such agent")) return HostErrorCode.NoSuchAgent;
359
+ return HostErrorCode.Internal;
360
+ }
361
+
362
+ function isRetryableAgentError(code: string): boolean {
363
+ return code === "FETCH_FAILED" || code === "CONTAINER_INIT_FAILED";
364
+ }
365
+
366
+ function toLegacyDecision(decision: PermissionDecision): "accept" | "decline" {
367
+ return decision.kind;
368
+ }
369
+
370
+ export type {
371
+ ChannelEnsureInput,
372
+ TaskDiffInput,
373
+ TaskDownInput,
374
+ TaskLaunchInput,
375
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Side-effect module: load `.env.local` then `.env` before host-agent modules
3
+ * read environment variables.
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const proc = process as unknown as {
12
+ loadEnvFile?: (path: string) => void;
13
+ };
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
16
+
17
+ // Resolve the same UAI_HOME as lib/env.ts, but independently: this module runs
18
+ // FIRST (before lib/env is imported) so it can't depend on it. .env.local is
19
+ // what *sets* UAI_DATA_DIR, so we can't key off that here — only an explicit
20
+ // UAI_HOME env, then repo detection, then ~/.uai (installed via npm).
21
+ function findUp(start: string, pred: (dir: string) => boolean): string | null {
22
+ let dir = start;
23
+ for (;;) {
24
+ if (pred(dir)) return dir;
25
+ const parent = dirname(dir);
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+
31
+ const repoRoot = findUp(
32
+ here,
33
+ (d) =>
34
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
35
+ existsSync(resolve(d, "host-agent")),
36
+ );
37
+
38
+ const uaiHome = process.env.UAI_HOME
39
+ ? resolve(process.env.UAI_HOME.replace(/^~(?=\/|$)/, homedir()))
40
+ : (repoRoot ?? resolve(homedir(), ".uai"));
41
+
42
+ for (const file of [".env.local", ".env"]) {
43
+ const path = join(uaiHome, file);
44
+ if (!proc.loadEnvFile || !existsSync(path)) continue;
45
+ try {
46
+ proc.loadEnvFile(path);
47
+ } catch (err) {
48
+ console.warn(
49
+ `[host-agent] could not load ${file}: ${(err as Error).message}`,
50
+ );
51
+ }
52
+ }