@runuai/host 0.2.8 → 0.3.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.
@@ -338,6 +338,14 @@ register({
338
338
  defaultModel: CLAUDE_DEFAULT_MODEL,
339
339
  supportedEfforts: () => [...CLAUDE_EFFORTS],
340
340
  defaultEffort: CLAUDE_DEFAULT_EFFORT,
341
+ // Usable only when a Claude credential is in the env (injected into task
342
+ // containers at task-up). Gates advertisement (ADR-044 P2).
343
+ available: () =>
344
+ Boolean(
345
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ||
346
+ process.env.ANTHROPIC_API_KEY ||
347
+ process.env.ANTHROPIC_AUTH_TOKEN,
348
+ ),
341
349
  create: async ({ agent, containerName, systemPreamble }) =>
342
350
  new ClaudeSession({ agent, containerName, systemPreamble }),
343
351
  });
@@ -28,6 +28,10 @@
28
28
  * namespaces inside it anyway.
29
29
  */
30
30
 
31
+ import { existsSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+
31
35
  import { newId } from "../ulid";
32
36
  import { dockerExecArgs, LineProcess } from "./proc";
33
37
  import { register } from "./registry";
@@ -517,6 +521,13 @@ register({
517
521
  defaultModel: CODEX_DEFAULT_MODEL,
518
522
  supportedEfforts: () => [...CODEX_EFFORTS],
519
523
  defaultEffort: CODEX_DEFAULT_EFFORT,
524
+ // Usable only when the owner's ~/.codex login exists — task-up copies it into
525
+ // each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
526
+ // (ADR-044 P2).
527
+ available: () =>
528
+ existsSync(
529
+ join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
530
+ ),
520
531
  create: async ({ agent, containerName, systemPreamble }) =>
521
532
  new CodexSession({ agent, containerName, systemPreamble }),
522
533
  });
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
32
32
  supportedEfforts(): string[];
33
33
  /** Preferred effort when the user doesn't pick one. */
34
34
  defaultEffort?: string;
35
+ /**
36
+ * Whether this kind is usable on THIS host right now — i.e. its credentials
37
+ * are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
38
+ * out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
39
+ * host can't actually run. Omit (or return true) to always advertise; the
40
+ * session factory stays permissive regardless.
41
+ */
42
+ available?: () => boolean;
35
43
  /** Builds an AgentSession for a roster entry of this kind. */
36
44
  create: AgentSessionFactory["create"];
37
45
  }
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
76
84
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
77
85
  */
78
86
  export function capabilities(): AgentKindCapability[] {
79
- return list().map((adapter) => {
87
+ return list()
88
+ .filter((adapter) => adapter.available?.() ?? true)
89
+ .map((adapter) => {
80
90
  const out: AgentKindCapability = {
81
91
  kind: adapter.kind,
82
92
  label: adapter.label,
package/lib/command-db.ts CHANGED
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
135
135
  id, owner_user_id, host_id, name, slug, branch, status,
136
136
  global_context, reviewer_order, agents, pr_context,
137
137
  worktree_path, compose_project, code_server_port, preview_ports,
138
- locked_at, started_at, ended_at
138
+ preview_env, locked_at, started_at, ended_at
139
139
  ) VALUES (
140
140
  @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
141
141
  @global_context, @reviewer_order, @agents, @pr_context,
142
142
  @worktree_path, @compose_project, @code_server_port, @preview_ports,
143
- @locked_at, @started_at, @ended_at
143
+ @preview_env, @locked_at, @started_at, @ended_at
144
144
  )`,
145
145
  ).run(task);
146
146
  }
@@ -166,6 +166,7 @@ interface CommandTaskRow {
166
166
  compose_project: string | null;
167
167
  code_server_port: number | null;
168
168
  preview_ports: string;
169
+ preview_env: string | null;
169
170
  locked_at: number | null;
170
171
  started_at: number | null;
171
172
  ended_at: number | null;
@@ -193,6 +194,7 @@ function withRuntime(
193
194
  compose_project: runtime?.composeProject ?? null,
194
195
  code_server_port: runtime?.codeServerPort ?? null,
195
196
  preview_ports: runtime?.previewPorts ?? "[]",
197
+ preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
196
198
  locked_at: runtime?.lockedAt ?? null,
197
199
  started_at: runtime?.startedAt ?? null,
198
200
  ended_at: runtime?.endedAt ?? null,
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
216
218
  compose_project: runtime?.composeProject ?? null,
217
219
  code_server_port: runtime?.codeServerPort ?? null,
218
220
  preview_ports: runtime?.previewPorts ?? "[]",
221
+ preview_env: null,
219
222
  locked_at: runtime?.lockedAt ?? null,
220
223
  started_at: runtime?.startedAt ?? null,
221
224
  ended_at: runtime?.endedAt ?? null,
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
262
265
  compose_project text,
263
266
  code_server_port integer,
264
267
  preview_ports text NOT NULL DEFAULT '[]',
268
+ preview_env text,
265
269
  pr_url text,
266
270
  locked_at integer,
267
271
  started_at integer,
package/lib/git-diff.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Parse `git diff` unified output into structured per-file patches.
3
3
  *
4
- * Input is whatever `git diff origin/<base>..HEAD` produces multi-file,
5
- * with headers, mode/rename/binary annotations, and `@@` hunks. The
6
- * output is a list of `DiffFile`s the UI can render per-file with
4
+ * Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
5
+ * appended `--no-index` new-file patches for untracked files produce —
6
+ * multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
7
+ * The output is a list of `DiffFile`s the UI can render per-file with
7
8
  * collapse/expand and syntax-highlighted hunks.
8
9
  *
9
10
  * Hand-rolled rather than pulled in as a dep — the unified-diff grammar
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
98
99
  containerName: string,
99
100
  cwd: string,
100
101
  range: string,
102
+ ): Promise<string> {
103
+ return runDiffCommand(
104
+ "docker",
105
+ [...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
106
+ "docker exec git diff",
107
+ process.env,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Render a single untracked file as a new-file patch by diffing it against
113
+ * `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
114
+ * entirely, so this is how they reach the "all changes vs base" view.
115
+ *
116
+ * `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
117
+ * case for a new file) and 0 when identical — both are success here; only
118
+ * code > 1 is a real error.
119
+ */
120
+ export function runGitDiffUntracked(
121
+ cwd: string,
122
+ relPath: string,
123
+ ): Promise<string> {
124
+ return runDiffCommand(
125
+ "git",
126
+ ["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
127
+ "git diff --no-index",
128
+ { ...process.env, ...NON_INTERACTIVE_GIT_ENV },
129
+ true,
130
+ );
131
+ }
132
+
133
+ /** In-container variant of {@link runGitDiffUntracked}. */
134
+ export function runGitDiffUntrackedInContainer(
135
+ containerName: string,
136
+ cwd: string,
137
+ relPath: string,
101
138
  ): Promise<string> {
102
139
  return runDiffCommand(
103
140
  "docker",
104
141
  [
105
- "exec",
106
- "-e",
107
- `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
108
- "-e",
109
- `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
110
- "-e",
111
- `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
112
- "-w",
113
- cwd,
114
- containerName,
142
+ ...dockerExecPrefix(containerName, cwd),
115
143
  "git",
116
144
  ...GIT_DIFF_ARGS,
117
- range,
145
+ "--no-index",
146
+ "--",
147
+ "/dev/null",
148
+ relPath,
118
149
  ],
119
- "docker exec git diff",
150
+ "docker exec git diff --no-index",
120
151
  process.env,
152
+ true,
121
153
  );
122
154
  }
123
155
 
156
+ /** `docker exec` argv prefix with the non-interactive git env + working dir. */
157
+ function dockerExecPrefix(containerName: string, cwd: string): string[] {
158
+ return [
159
+ "exec",
160
+ "-e",
161
+ `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
162
+ "-e",
163
+ `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
164
+ "-e",
165
+ `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
166
+ "-w",
167
+ cwd,
168
+ containerName,
169
+ ];
170
+ }
171
+
124
172
  function runDiffCommand(
125
173
  command: string,
126
174
  args: string[],
127
175
  label: string,
128
176
  env: NodeJS.ProcessEnv,
177
+ /** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
178
+ * compared inputs differ, which for a new-file patch is the expected case. */
179
+ allowExitOne = false,
129
180
  ): Promise<string> {
130
181
  return new Promise((res, rej) => {
131
182
  // Force ASCII paths and pull a generous unified context so the UI
@@ -144,7 +195,7 @@ function runDiffCommand(
144
195
  });
145
196
  child.on("error", rej);
146
197
  child.on("close", (code) => {
147
- if (code === 0) {
198
+ if (code === 0 || (allowExitOne && code === 1)) {
148
199
  res(stdout);
149
200
  return;
150
201
  }
@@ -235,14 +235,13 @@ class Orchestrator {
235
235
  });
236
236
  }
237
237
 
238
- // Container is ready: deliver each agent's first-turn prompt (ADR-022).
239
- // Only agents whose `initialPrompt` was non-empty have an entry.
240
- for (const agent of channel.roster) {
241
- const firstTurn = channel.firstTurns.get(agent.id);
242
- if (firstTurn === undefined) continue;
243
- const session = channel.sessions.get(agent.id);
244
- if (session) void session.send(firstTurn);
245
- }
238
+ // First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
239
+ // which fires each agent's initialPrompt exactly once per task — guarded by
240
+ // a non-empty message history (its durable "already started" check). The
241
+ // host must NOT deliver it here: this path re-runs on every session spawn,
242
+ // so it both duplicated the opening turn AND re-injected it on a host
243
+ // restart, making agents redo the first turn mid-task. The persona/mission
244
+ // an agent needs live in the always-on system preamble, so nothing is lost.
246
245
  return true;
247
246
  }
248
247
 
@@ -723,7 +722,11 @@ export function buildSystemPreamble(
723
722
  workspacePath: string,
724
723
  taskBranch: string,
725
724
  ): string {
726
- const others = roster.map((a) => `@${a.id} (${a.label})`).join(", ");
725
+ const channelList = roster
726
+ .map((a) =>
727
+ a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
728
+ )
729
+ .join(", ");
727
730
  const projectLines =
728
731
  projects.length === 0
729
732
  ? ["(none mounted)"]
@@ -739,19 +742,51 @@ export function buildSystemPreamble(
739
742
  "it by id at the start of a line — e.g. `@codex please review the",
740
743
  "diff`. uai routes that message into that agent's input.",
741
744
  "",
742
- `Agents in this channel: ${others}.`,
745
+ `**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
746
+ `Introduce and refer to yourself as ${agent.label}, not as a generic`,
747
+ "assistant or model name.",
748
+ "",
749
+ `Agents in this channel: ${channelList}.`,
743
750
  "",
744
- "The human you're working with is **@you**. When you need their",
745
- "input, a decision, or their attention a question, a blocker, an",
746
- "approval, or you've finished and are handing back to them end your",
747
- "message by @-mentioning **@you** (e.g. `@you which approach do you`",
748
- "`prefer?` or `@you done — PR is up for review`). That notifies them.",
751
+ "The human you're working with is **@you**. @-mentioning them sends a",
752
+ "NOTIFICATION, so use it sparinglyonly when you actually need them: a",
753
+ "decision you can't make, a blocker, an approval, or you've finished your",
754
+ "work and are handing it back for them to act on (e.g. `@you which`",
755
+ "`approach do you prefer?` or `@you done — PR is up for review`). For",
756
+ "everything else — status updates, thinking out loud, a direct reply to",
757
+ "something they just asked, acknowledgments — post in the channel WITHOUT",
758
+ "@-mentioning @you; they can read the channel and don't need a ping for",
759
+ "every message. Do NOT reflexively end messages with @you.",
749
760
  "",
750
761
  "An agent only receives a message when it is explicitly @-mentioned",
751
762
  "(or addressed by the human) — so always @-mention the agent (or @you)",
752
763
  "you mean. There is NO `peer` command and no shared tmux session;",
753
764
  "hand-offs are just @-mentions in your replies.",
754
765
  "",
766
+ "Collaborate with your peers — divide up the work, review each other's",
767
+ "changes, share concrete ideas, and debate approach decisions by",
768
+ "@-mentioning them. That is how the team gets things done, and you",
769
+ "should do it freely whenever it moves the work forward. But mentioning",
770
+ "a peer WAKES it and costs a turn, so make each one count: a message to",
771
+ "a peer should ADVANCE the work — a real proposal, a question you need",
772
+ "answered, a hand-off, or a review with specific findings. Do NOT",
773
+ "@-mention a peer just to greet, thank, agree, acknowledge, or say",
774
+ "you're ready — content-free replies wake them for nothing and spiral",
775
+ "into endless back-and-forth. If you have nothing substantive to add,",
776
+ "don't @-mention back. And when there's no active task yet (intros, or",
777
+ "you're waiting on the human), answer briefly and then wait — you don't",
778
+ "need to @-mention anyone (including @you); they can see the channel.",
779
+ "",
780
+ "When a message already @-mentions several participants at once (the",
781
+ "human asking the whole group, or a peer addressing multiple agents),",
782
+ "it's a group broadcast — this is a GROUP CHAT and everyone named has",
783
+ "ALREADY been notified and will answer for themselves. Just answer for",
784
+ "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
785
+ "question to them, or wait on them — no `I'll let @x speak`, `@x your",
786
+ "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
787
+ "the message only wakes them again and spirals into duplicate replies.",
788
+ "Say your piece and stop.",
789
+ "",
755
790
  "Because your input is only what you're addressed, you may be missing",
756
791
  "context from messages between the human and the other agents. The full",
757
792
  "channel transcript — every message + who wrote it (no tool calls) — is",
@@ -763,9 +798,11 @@ export function buildSystemPreamble(
763
798
  "and committed your changes, or completed a review, end your reply by",
764
799
  "@-mentioning the agent who should act next and telling them what you",
765
800
  "did and what you need (e.g. `@codex changes committed on <branch> —",
766
- "please review`, or `@claude review done, N issues to fix`). Never",
767
- "stop silently — if genuinely no agent needs to act, say so to the",
768
- "human.",
801
+ "please review`, or `@claude review done, N issues to fix`). Don't",
802
+ "abandon unfinished work silently — but once your part is done and no",
803
+ "peer needs to act, it's fine to stop; only @-mention @you if you need",
804
+ "their input or are handing back finished work for them to act on. Don't",
805
+ "prolong an agent-to-agent exchange just to fill silence.",
769
806
  "",
770
807
  "## Workspace layout",
771
808
  "",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ADR-043: per-(task, preview) node-proxy sidecar for ad-hoc preview ports.
3
+ *
4
+ * Ad-hoc preview ports aren't Docker-published, and the app container's bridge
5
+ * IP isn't host-routable on macOS/OrbStack (EHOSTUNREACH). So proxy through a
6
+ * tiny sidecar: a container on the task's compose network that PUBLISHES a
7
+ * `127.0.0.1` host port (reachable on every backend) and forwards to
8
+ * `<app-container>:<containerPort>`. The sidecar runs the task's OWN image
9
+ * (always local — no registry pull) under `node` with a ~5-line `net` TCP proxy;
10
+ * node streams give backpressure + handle WebSockets (Metro/HMR).
11
+ *
12
+ * Hot path is cache-only (NO docker call per request — that thrashed under a
13
+ * browser's concurrent requests). The cached port is trusted until a tunnel
14
+ * connect fails, which invalidates it (mirrors the container-IP cache). Declared
15
+ * ports keep their published-port path; only ad-hoc ports use a sidecar.
16
+ */
17
+ import { dockerCli } from "./docker-exec";
18
+
19
+ /** Port the proxy listens on inside the sidecar (published to a random host port). */
20
+ const INNER_PORT = 9000;
21
+ const LABEL = "com.runuai.preview-sidecar.task";
22
+
23
+ /** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
24
+ const cache = new Map<string, number>();
25
+ /** In-flight creates, so concurrent requests for the same key share one sidecar. */
26
+ const inflight = new Map<string, Promise<number | null>>();
27
+
28
+ function cacheKey(taskId: string, name: string, containerPort: number): string {
29
+ return `${taskId} ${name} ${containerPort}`;
30
+ }
31
+
32
+ function sidecarName(taskId: string, name: string): string {
33
+ return `uai-preview-${taskId}-${name}`;
34
+ }
35
+
36
+ function proxyScript(appContainer: string, containerPort: number): string {
37
+ // Bidirectional TCP pipe: 0.0.0.0:INNER_PORT <-> appContainer:containerPort.
38
+ return (
39
+ `const net=require('net');` +
40
+ `net.createServer(c=>{` +
41
+ `const u=net.connect(${containerPort},${JSON.stringify(appContainer)});` +
42
+ `c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());` +
43
+ `c.pipe(u);u.pipe(c);` +
44
+ `}).listen(${INNER_PORT},'0.0.0.0');`
45
+ );
46
+ }
47
+
48
+ async function imageOf(appContainer: string): Promise<string | null> {
49
+ const r = await dockerCli(
50
+ ["inspect", "-f", "{{.Config.Image}}", appContainer],
51
+ { timeoutMs: 5_000 },
52
+ );
53
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
54
+ }
55
+
56
+ /** `docker port <name>` -> the published 127.0.0.1 host port, or null. */
57
+ async function publishedPort(name: string): Promise<number | null> {
58
+ const r = await dockerCli(["port", name, String(INNER_PORT)], {
59
+ timeoutMs: 5_000,
60
+ });
61
+ const m = r.stdout.match(/127\.0\.0\.1:(\d+)/);
62
+ return r.status === 0 && m ? Number(m[1]) : null;
63
+ }
64
+
65
+ async function createSidecar(args: {
66
+ taskId: string;
67
+ composeProject: string;
68
+ name: string;
69
+ containerPort: number;
70
+ }): Promise<number | null> {
71
+ const name = sidecarName(args.taskId, args.name);
72
+ const appContainer = `${args.composeProject}-app-1`;
73
+
74
+ // Reuse a still-running sidecar from a previous host process (--rm keeps it up
75
+ // across a host restart; the in-memory cache was cleared). `docker port` is an
76
+ // exact-name lookup, so no name-filter regex pitfalls.
77
+ const existing = await publishedPort(name);
78
+ if (existing !== null) return existing;
79
+
80
+ const image = await imageOf(appContainer);
81
+ if (!image) return null;
82
+
83
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
84
+ const run = await dockerCli(
85
+ [
86
+ "run", "-d", "--rm",
87
+ "--name", name,
88
+ "--network", `${args.composeProject}_default`,
89
+ "--label", `${LABEL}=${args.taskId}`,
90
+ "-p", `127.0.0.1::${INNER_PORT}`,
91
+ "--entrypoint", "node",
92
+ image,
93
+ "-e", proxyScript(appContainer, args.containerPort),
94
+ ],
95
+ { timeoutMs: 20_000 },
96
+ );
97
+ if (run.status !== 0) return null;
98
+
99
+ const hostPort = await publishedPort(name);
100
+ if (hostPort === null) {
101
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
102
+ return null;
103
+ }
104
+ return hostPort;
105
+ }
106
+
107
+ /**
108
+ * Ensure a sidecar is proxying `<app>:<containerPort>` for (taskId, name);
109
+ * returns the `127.0.0.1` host port it publishes, or null on failure. Cache-only
110
+ * on the hot path; a cache miss creates the sidecar (deduped across concurrent
111
+ * callers).
112
+ */
113
+ export async function ensurePreviewSidecar(args: {
114
+ taskId: string;
115
+ composeProject: string;
116
+ name: string;
117
+ containerPort: number;
118
+ }): Promise<number | null> {
119
+ const key = cacheKey(args.taskId, args.name, args.containerPort);
120
+ const cached = cache.get(key);
121
+ if (cached !== undefined) return cached;
122
+
123
+ let pending = inflight.get(key);
124
+ if (!pending) {
125
+ pending = createSidecar(args)
126
+ .then((port) => {
127
+ if (port !== null) cache.set(key, port);
128
+ return port;
129
+ })
130
+ .finally(() => inflight.delete(key));
131
+ inflight.set(key, pending);
132
+ }
133
+ return pending;
134
+ }
135
+
136
+ /** Drop any cached sidecar entry on `hostPort` (a tunnel connect failed). No-op
137
+ * for published-declared / container-IP targets, which aren't in this cache. */
138
+ export function invalidatePreviewSidecar(hostPort: number): void {
139
+ for (const [key, port] of cache) {
140
+ if (port === hostPort) cache.delete(key);
141
+ }
142
+ }
143
+
144
+ /** Stop + remove all preview sidecars for a task (called on task-down). */
145
+ export async function stopPreviewSidecars(taskId: string): Promise<void> {
146
+ for (const key of [...cache.keys()]) {
147
+ if (key.startsWith(`${taskId} `)) cache.delete(key);
148
+ }
149
+ const r = await dockerCli(
150
+ ["ps", "-aq", "--filter", `label=${LABEL}=${taskId}`],
151
+ { timeoutMs: 5_000 },
152
+ );
153
+ if (r.status !== 0) return;
154
+ for (const id of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) {
155
+ await dockerCli(["rm", "-f", id], { timeoutMs: 10_000 });
156
+ }
157
+ }
package/lib/task-diff.ts CHANGED
@@ -4,9 +4,20 @@ import { join } from "node:path";
4
4
 
5
5
  import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
6
6
  import { taskWorkspaceDir } from "./env";
7
- import { parseGitDiff, runGitDiff, runGitDiffInContainer } from "./git-diff";
7
+ import {
8
+ parseGitDiff,
9
+ runGitDiff,
10
+ runGitDiffInContainer,
11
+ runGitDiffUntracked,
12
+ runGitDiffUntrackedInContainer,
13
+ } from "./git-diff";
8
14
  import { getHostTask } from "./runtime-state";
9
15
 
16
+ // Cap how many untracked files we render as new-file patches. Each is a
17
+ // separate (in-container) git spawn, so a pathological untracked dir that
18
+ // slips past .gitignore shouldn't turn the diff into thousands of execs.
19
+ const MAX_UNTRACKED = 200;
20
+
10
21
  export async function buildTaskDiff(
11
22
  input: TaskDiffInput,
12
23
  ): Promise<TaskDiffResult> {
@@ -23,15 +34,40 @@ export async function buildTaskDiff(
23
34
  const containerCwd = `/workspace/${project.slug}`;
24
35
  if (!containerName && !existsSync(cwd)) continue;
25
36
 
26
- // Base branch is derived from the worktree's upstream (the worktree was
27
- // created off `origin/<defaultBranch>` at task-up; ADR-022 derives and
28
- // persists the default branch on the worktree, not the project record).
37
+ // Base branch is the remote's default branch (where task branches are cut
38
+ // from at task-up) NOT the worktree's @{upstream}, which flips to
39
+ // origin/<task-branch> once the branch is pushed and would then hide all
40
+ // committed work (see resolveBase).
29
41
  const base = resolveBase(containerName, cwd, containerCwd, project.slug);
30
- const range = `${base}..HEAD`;
42
+ // Diff the whole branch against base, including UNCOMMITTED work: compare
43
+ // the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
44
+ // The merge-base keeps base's own later commits from showing up reversed if
45
+ // it advanced; falling back to `base` itself if merge-base can't resolve.
46
+ const mergeBase =
47
+ runGitText(containerName, cwd, containerCwd, [
48
+ "merge-base",
49
+ base,
50
+ "HEAD",
51
+ ]) ?? base;
31
52
  try {
32
- const text = containerName
33
- ? await runGitDiffInContainer(containerName, containerCwd, range)
34
- : await runGitDiff(cwd, range);
53
+ // Tracked changes (committed + staged + unstaged): a commit-ish vs the
54
+ // working tree, so omitting `..HEAD` is the point.
55
+ let text = containerName
56
+ ? await runGitDiffInContainer(containerName, containerCwd, mergeBase)
57
+ : await runGitDiff(cwd, mergeBase);
58
+
59
+ // Untracked files: `git diff <commit>` skips them, so list (honouring
60
+ // .gitignore) and append each as a /dev/null new-file patch.
61
+ const untracked = listUntracked(containerName, cwd, containerCwd);
62
+ for (const file of untracked) {
63
+ const patch = containerName
64
+ ? await runGitDiffUntrackedInContainer(containerName, containerCwd, file)
65
+ : await runGitDiffUntracked(cwd, file);
66
+ if (patch.trim().length === 0) continue;
67
+ if (text.length > 0 && !text.endsWith("\n")) text += "\n";
68
+ text += patch;
69
+ }
70
+
35
71
  out.push({
36
72
  id: project.id,
37
73
  name: project.slug,
@@ -55,10 +91,16 @@ export async function buildTaskDiff(
55
91
  }
56
92
 
57
93
  /**
58
- * Resolve the diff base for a worktree. Prefer the configured upstream
59
- * (`@{upstream}`); fall back to the remote's default branch
60
- * (`origin/HEAD`), then `origin/main`. Runs git in-container when the task
61
- * is live so credential behavior stays inside the sandbox (see git-diff).
94
+ * Resolve the diff base for a worktree: the remote's default branch
95
+ * (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
96
+ * `origin/main`. Runs git in-container when the task is live so credential
97
+ * behavior stays inside the sandbox (see git-diff).
98
+ *
99
+ * We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
100
+ * `origin/<defaultBranch>`, but once the task branch is pushed (e.g. a PR is
101
+ * opened) its upstream flips to `origin/<task-branch>` — then
102
+ * `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
103
+ * work instead of the whole branch.
62
104
  */
63
105
  function resolveBase(
64
106
  containerName: string | null,
@@ -66,14 +108,6 @@ function resolveBase(
66
108
  containerCwd: string,
67
109
  _slug: string,
68
110
  ): string {
69
- const upstream = runGitText(
70
- containerName,
71
- hostCwd,
72
- containerCwd,
73
- ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
74
- );
75
- if (upstream) return upstream;
76
-
77
111
  const head = runGitText(
78
112
  containerName,
79
113
  hostCwd,
@@ -89,6 +123,27 @@ function stripOriginPrefix(ref: string): string {
89
123
  return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
90
124
  }
91
125
 
126
+ /**
127
+ * List untracked files in the worktree, honouring .gitignore
128
+ * (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
129
+ */
130
+ function listUntracked(
131
+ containerName: string | null,
132
+ hostCwd: string,
133
+ containerCwd: string,
134
+ ): string[] {
135
+ const raw = runGitText(containerName, hostCwd, containerCwd, [
136
+ "ls-files",
137
+ "--others",
138
+ "--exclude-standard",
139
+ ]);
140
+ if (!raw) return [];
141
+ return raw
142
+ .split("\n")
143
+ .filter((line) => line.length > 0)
144
+ .slice(0, MAX_UNTRACKED);
145
+ }
146
+
92
147
  /**
93
148
  * Run a read-only git command (host or in-container) and return trimmed
94
149
  * stdout, or null on any failure. Used for base-branch resolution where a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -309,11 +309,11 @@ fi
309
309
  done < <(jq -c '.[]' <<<"$projects_json")
310
310
  printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
311
311
  printf ' ports:\n'
312
+ # code-server (the Editor tunnel) is the only auto-published port. Preview
313
+ # ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
314
+ # each preview the user enables is reached lazily through a per-(task,preview)
315
+ # sidecar attached to the compose network, so nothing is exposed by default.
312
316
  printf ' - "127.0.0.1::8080"\n'
313
- while IFS= read -r cport; do
314
- [ -n "$cport" ] || continue
315
- printf ' - "127.0.0.1::%s"\n' "$cport"
316
- done < <(jq -r '.[].containerPort' <<<"$union_preview_ports_json")
317
317
  printf ' environment:\n'
318
318
  # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
319
319
  # uses the interactive subscription/keychain path, so it needs a token from
@@ -345,6 +345,19 @@ fi
345
345
  esac
346
346
  printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
347
347
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
348
+ # Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
349
+ # under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
350
+ # written LITERALLY (unlike the ${KEY:-} pass-throughs above). `preview_env`
351
+ # is a JSON object {VAR: url} on the task row, one entry per preview port that
352
+ # set `urlEnv`. Emitted last so it wins over any same-named declared key.
353
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
354
+ while IFS= read -r pe_entry; do
355
+ [ -n "$pe_entry" ] || continue
356
+ pe_key=$(jq -r '.key' <<<"$pe_entry")
357
+ pe_val=$(jq -r '.value' <<<"$pe_entry")
358
+ [ -n "$pe_key" ] || continue
359
+ printf ' %s: "%s"\n' "$pe_key" "$pe_val"
360
+ done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
348
361
  printf 'volumes:\n'
349
362
  printf ' %s:\n' "$ASDF_VOLUME"
350
363
  printf ' external: true\n'
@@ -441,29 +454,12 @@ if [ -z "$code_server_port" ]; then
441
454
  log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
442
455
  fi
443
456
 
457
+ # ADR-043 Stage 2: preview ports are no longer published at task-up, so there is
458
+ # nothing to discover here. `preview_ports` stays empty and every enabled preview
459
+ # is reached through its sidecar (started lazily on first access via the cloud
460
+ # tunnel). `union_preview_ports_json` is still surfaced to the cloud (GET task
461
+ # lists the declared/ad-hoc names the user can turn on).
444
462
  preview_ports_runtime_json="[]"
445
- if [ "$(jq 'length' <<<"$union_preview_ports_json")" -gt 0 ]; then
446
- preview_port_lines=""
447
- while IFS= read -r preview_obj; do
448
- [ -n "$preview_obj" ] || continue
449
- preview_name=$(jq -r '.name' <<<"$preview_obj")
450
- preview_container_port=$(jq -r '.containerPort' <<<"$preview_obj")
451
- preview_host_port=$(mapped_host_port "$app_container" "$preview_container_port")
452
- if [ -z "$preview_host_port" ]; then
453
- log "warning: could not discover preview port ${preview_name}:${preview_container_port} for ${app_container}"
454
- continue
455
- fi
456
- preview_port_lines="${preview_port_lines}$(jq -nc \
457
- --arg name "$preview_name" \
458
- --arg hp "$preview_host_port" \
459
- '{name:$name,hostPort:($hp|tonumber)}')
460
- "
461
- done < <(jq -c '.[]' <<<"$union_preview_ports_json")
462
-
463
- if [ -n "$preview_port_lines" ]; then
464
- preview_ports_runtime_json=$(printf '%s' "$preview_port_lines" | jq -s -c '.')
465
- fi
466
- fi
467
463
 
468
464
  step "DB_UPDATE_FAILED" "mark task running"
469
465
  cs_sql="code_server_port=NULL"
package/src/main.ts CHANGED
@@ -49,6 +49,12 @@ import {
49
49
  } from "./paths";
50
50
  import { dockerMemoryBytes, startUiServer } from "./ui/server";
51
51
  import { parsePreviewPortRuntimes } from "../lib/preview-ports";
52
+ import { dockerCli } from "../lib/docker-exec";
53
+ import {
54
+ ensurePreviewSidecar,
55
+ invalidatePreviewSidecar,
56
+ stopPreviewSidecars,
57
+ } from "../lib/preview-sidecar";
52
58
  import { newId } from "../lib/ulid";
53
59
  import {
54
60
  capabilities as agentKindCapabilities,
@@ -262,7 +268,7 @@ function connect(): void {
262
268
  void handleCommand(socket, frame);
263
269
  break;
264
270
  case "tunnel.open":
265
- handleTunnelOpen(socket, frame);
271
+ void handleTunnelOpen(socket, frame);
266
272
  break;
267
273
  case "tunnel.data":
268
274
  pendingBinaryTunnelId = frame.tunnelId;
@@ -417,12 +423,12 @@ async function handleCommand(
417
423
  }
418
424
  }
419
425
 
420
- function handleTunnelOpen(
426
+ async function handleTunnelOpen(
421
427
  wsSocket: WebSocket,
422
428
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
423
- ): void {
424
- const port = resolveTunnelPort(frame);
425
- if (!port) {
429
+ ): Promise<void> {
430
+ const target = await resolveTunnelTarget(frame);
431
+ if (!target) {
426
432
  send(wsSocket, {
427
433
  kind: "tunnel.ack",
428
434
  tunnelId: frame.tunnelId,
@@ -434,23 +440,23 @@ function handleTunnelOpen(
434
440
  }
435
441
 
436
442
  if (!frame.upgrade) {
437
- handleHttpTunnelOpen(wsSocket, frame, port);
443
+ handleHttpTunnelOpen(wsSocket, frame, target);
438
444
  return;
439
445
  }
440
446
 
441
- handleRawTunnelOpen(wsSocket, frame, port);
447
+ handleRawTunnelOpen(wsSocket, frame, target);
442
448
  }
443
449
 
444
450
  function handleHttpTunnelOpen(
445
451
  wsSocket: WebSocket,
446
452
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
447
- port: number,
453
+ target: UpstreamAddr,
448
454
  ): void {
449
455
  let acked = false;
450
456
  const upstream = httpRequest(
451
457
  {
452
- host: "127.0.0.1",
453
- port,
458
+ host: target.host,
459
+ port: target.port,
454
460
  method: frame.reqLine.method,
455
461
  path: frame.reqLine.url,
456
462
  headers: requestHeaders(frame.reqLine.headers),
@@ -485,6 +491,13 @@ function handleHttpTunnelOpen(
485
491
 
486
492
  upstream.on("error", (err) => {
487
493
  tunnels.delete(frame.tunnelId);
494
+ // A connect failure to a cached container IP likely means the container was
495
+ // recreated (resume) and got a new IP — drop the cache so the next request
496
+ // re-resolves (ADR-036).
497
+ invalidateContainerIpByAddr(target.host);
498
+ // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
499
+ // the next request recreates it (no-op for published / container-IP targets).
500
+ invalidatePreviewSidecar(target.port);
488
501
  if (!acked) {
489
502
  send(wsSocket, {
490
503
  kind: "tunnel.ack",
@@ -511,7 +524,7 @@ function handleHttpTunnelOpen(
511
524
  function handleRawTunnelOpen(
512
525
  wsSocket: WebSocket,
513
526
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
514
- port: number,
527
+ target: UpstreamAddr,
515
528
  ): void {
516
529
  const upstream = new Socket();
517
530
  tunnels.set(frame.tunnelId, upstream);
@@ -547,6 +560,10 @@ function handleRawTunnelOpen(
547
560
  });
548
561
 
549
562
  upstream.on("error", (err) => {
563
+ invalidateContainerIpByAddr(target.host);
564
+ // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
565
+ // the next request recreates it (no-op for published / container-IP targets).
566
+ invalidatePreviewSidecar(target.port);
550
567
  if (!acked) {
551
568
  send(wsSocket, {
552
569
  kind: "tunnel.ack",
@@ -574,7 +591,7 @@ function handleRawTunnelOpen(
574
591
  });
575
592
  });
576
593
 
577
- upstream.connect(port, "127.0.0.1");
594
+ upstream.connect(target.port, target.host);
578
595
  }
579
596
 
580
597
  function closeTunnel(
@@ -588,17 +605,85 @@ function closeTunnel(
588
605
  send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
589
606
  }
590
607
 
591
- function resolveTunnelPort(
608
+ /** Where a tunnel's bytes go: `127.0.0.1:<published>` for the editor, or a
609
+ * container's `<ip>:<containerPort>` for a preview (ADR-036). */
610
+ interface UpstreamAddr {
611
+ host: string;
612
+ port: number;
613
+ }
614
+
615
+ // Container IP cache (ADR-036): `docker inspect` is too slow for the request
616
+ // hot path, so cache the app container's Docker-network IP per compose project.
617
+ // Short TTL + invalidate-on-connect-error so a resumed container's new IP heals.
618
+ const CONTAINER_IP_TTL_MS = 60_000;
619
+ const containerIpCache = new Map<string, { ip: string; ts: number }>();
620
+
621
+ async function resolveContainerIp(composeProject: string): Promise<string | null> {
622
+ const container = `${composeProject}-app-1`;
623
+ const cached = containerIpCache.get(container);
624
+ if (cached && Date.now() - cached.ts < CONTAINER_IP_TTL_MS) return cached.ip;
625
+ const res = await dockerCli(
626
+ [
627
+ "inspect",
628
+ "-f",
629
+ "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
630
+ container,
631
+ ],
632
+ { timeoutMs: 5_000 },
633
+ );
634
+ if (res.status !== 0) return null;
635
+ const ip = res.stdout.trim();
636
+ if (!ip) return null;
637
+ containerIpCache.set(container, { ip, ts: Date.now() });
638
+ return ip;
639
+ }
640
+
641
+ /** Drop any cached container IP equal to `addr` (no-op for `127.0.0.1`). */
642
+ function invalidateContainerIpByAddr(addr: string): void {
643
+ if (addr === "127.0.0.1") return;
644
+ for (const [key, val] of containerIpCache) {
645
+ if (val.ip === addr) containerIpCache.delete(key);
646
+ }
647
+ }
648
+
649
+ async function resolveTunnelTarget(
592
650
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
593
- ): number | null {
651
+ ): Promise<UpstreamAddr | null> {
594
652
  const task = getHostTask(frame.taskId);
595
653
  if (!task) return null;
596
- if (frame.target === "editor") return task.codeServerPort ?? null;
597
- if (!frame.name) return null;
598
- const preview = parsePreviewPortRuntimes(task.previewPorts).find(
599
- (port) => port.name === frame.name,
600
- );
601
- return preview?.hostPort ?? null;
654
+ if (frame.target === "editor") {
655
+ return task.codeServerPort
656
+ ? { host: "127.0.0.1", port: task.codeServerPort }
657
+ : null;
658
+ }
659
+ // Preview. Prefer the PUBLISHED host port for declared previews (published at
660
+ // task-up): a 127.0.0.1 port that's reachable on every backend, incl.
661
+ // macOS/OrbStack where the container bridge IP is NOT host-routable (ADR-043).
662
+ if (frame.name) {
663
+ const declared = parsePreviewPortRuntimes(task.previewPorts).find(
664
+ (port) => port.name === frame.name,
665
+ );
666
+ if (declared) return { host: "127.0.0.1", port: declared.hostPort };
667
+ // Ad-hoc port (not published): proxy via a node-proxy sidecar that publishes
668
+ // a 127.0.0.1 port forwarding to <app>:<containerPort> (ADR-043).
669
+ if (frame.containerPort && task.composeProject) {
670
+ const hostPort = await ensurePreviewSidecar({
671
+ taskId: frame.taskId,
672
+ composeProject: task.composeProject,
673
+ name: frame.name,
674
+ containerPort: frame.containerPort,
675
+ });
676
+ if (hostPort) return { host: "127.0.0.1", port: hostPort };
677
+ }
678
+ }
679
+ // Linux fallback (pre-ADR-043 / non-macOS host where bridge IPs route): proxy
680
+ // straight to the container IP. Unreachable on macOS/OrbStack — the
681
+ // connect-error path returns 502 there.
682
+ if (frame.containerPort && task.composeProject) {
683
+ const ip = await resolveContainerIp(task.composeProject);
684
+ if (ip) return { host: ip, port: frame.containerPort };
685
+ }
686
+ return null;
602
687
  }
603
688
 
604
689
  function serializeRequest(reqLine: {
@@ -727,8 +812,12 @@ function dispatchCommand(
727
812
  switch (command) {
728
813
  case "taskUp":
729
814
  return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
730
- case "taskDown":
731
- return hostCommands.taskDown(ctx, expectTaskDownInput(args, 0));
815
+ case "taskDown": {
816
+ const downInput = expectTaskDownInput(args, 0);
817
+ // ADR-043: tear down this task's preview sidecars. Best-effort.
818
+ void stopPreviewSidecars(downInput.taskId).catch(() => {});
819
+ return hostCommands.taskDown(ctx, downInput);
820
+ }
732
821
  case "taskStatus":
733
822
  return hostCommands.taskStatus(ctx, expectString(args, 0));
734
823
  case "channelEnsure":
@@ -821,6 +910,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
821
910
  target: frame.target,
822
911
  taskId: frame.taskId,
823
912
  name: typeof frame.name === "string" ? frame.name : undefined,
913
+ // ADR-043: cloud-resolved in-container port for ad-hoc previews; feeds the
914
+ // node-proxy sidecar (NOT the unreachable bridge IP — see resolveTunnelTarget).
915
+ containerPort:
916
+ typeof frame.containerPort === "number" ? frame.containerPort : undefined,
824
917
  reqLine: frame.reqLine,
825
918
  upgrade: frame.upgrade,
826
919
  };
@@ -1139,6 +1232,17 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
1139
1232
  expectStringValue(id, `task.reviewerOrder[${i}]`),
1140
1233
  );
1141
1234
  }
1235
+ if (
1236
+ row.previewEnv &&
1237
+ typeof row.previewEnv === "object" &&
1238
+ !Array.isArray(row.previewEnv)
1239
+ ) {
1240
+ const pe: Record<string, string> = {};
1241
+ for (const [k, v] of Object.entries(row.previewEnv as Record<string, unknown>)) {
1242
+ if (typeof v === "string") pe[k] = v;
1243
+ }
1244
+ if (Object.keys(pe).length > 0) out.previewEnv = pe;
1245
+ }
1142
1246
  return out;
1143
1247
  }
1144
1248
 
package/src/protocol.ts CHANGED
@@ -115,6 +115,14 @@ export interface TaskCommandTask {
115
115
  globalContext?: string;
116
116
  reviewerOrder?: string[];
117
117
  agents: TaskAgent[];
118
+ /**
119
+ * Cloud-computed PUBLIC preview URLs to inject into the container env at
120
+ * task-up, keyed by the operator-chosen var name (e.g.
121
+ * `EXPO_PACKAGER_PROXY_URL`). Non-secret — written literally into the task's
122
+ * compose file. Only present on task-up commands, when a preview port set
123
+ * `urlEnv` and the cloud has a preview base domain configured.
124
+ */
125
+ previewEnv?: Record<string, string>;
118
126
  }
119
127
 
120
128
  /**
@@ -299,6 +307,11 @@ export type CloudToHost =
299
307
  target: TunnelTarget;
300
308
  taskId: string;
301
309
  name?: string;
310
+ // The in-container port to reach for a preview (ADR-036). The cloud
311
+ // resolves it from the task's declared + ad-hoc preview ports; the host
312
+ // proxies to <container-ip>:<containerPort> over the Docker network.
313
+ // Absent for the editor target (which uses the published codeServerPort).
314
+ containerPort?: number;
302
315
  reqLine: TunnelReqLine;
303
316
  upgrade: boolean;
304
317
  }