@runuai/host 0.4.2 → 0.5.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.
@@ -50,8 +50,14 @@ import {
50
50
  loadTaskCliSecret,
51
51
  writeAgentCli,
52
52
  } from "./agent-cli";
53
+ import { setupBrowserTesting } from "./browser-testing";
54
+ import { setupMcpTaskConfig } from "./mcp-gateway";
53
55
  import { env } from "./env";
54
- import type { ChannelEnsureInput, HostEvent } from "../src/protocol";
56
+ import type {
57
+ ChannelEnsureInput,
58
+ ChannelHuman,
59
+ HostEvent,
60
+ } from "../src/protocol";
55
61
 
56
62
  export type HostEventSubscriber = (event: HostEvent) => void;
57
63
 
@@ -86,10 +92,29 @@ interface Channel {
86
92
  /** Per-agent respawn counter — bounded so a broken agent can't
87
93
  * loop forever rewriting its config. */
88
94
  respawns: Map<string, number>;
95
+ /** When each agent last burned a respawn — a budget older than the
96
+ * cooldown resets, so a task self-heals once the underlying failure
97
+ * (missing CLI, broken config) clears instead of needing an operator. */
98
+ respawnLastAt: Map<string, number>;
99
+ /** ADR-049: humans in the chat (from the latest channel spec). */
100
+ humans: ChannelHuman[];
101
+ /** ADR-053: wire the Playwright MCP browser at session start. */
102
+ browserTesting: boolean;
103
+ /** ADR-057: the owner's MCP connections exposed through the host gateway. */
104
+ mcpConnections: Array<{ id: string; slug: string }>;
105
+ /** Last connection set written into the container (skip repeat execs —
106
+ * ensure runs on every message). Live sessions read MCP config at spawn,
107
+ * so a mid-task write takes effect on the next (re)spawn. */
108
+ mcpConfigFingerprint?: string;
109
+ /** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
110
+ * against a concurrent ensure double-spawning the same new agent. */
111
+ spawning: Set<string>;
89
112
  }
90
113
 
91
114
  /** Hard cap on automatic respawns per agent per channel lifetime. */
92
115
  const MAX_RESPAWNS_PER_AGENT = 5;
116
+ /** A burned respawn budget resets after this quiet period (see reconcile). */
117
+ const RESPAWN_COOLDOWN_MS = 10 * 60_000;
93
118
 
94
119
  /** Substrings in an agent's error output that mean "config was
95
120
  * unlinked between runs" — repair-and-respawn covers the common
@@ -133,6 +158,44 @@ class Orchestrator {
133
158
 
134
159
  registerChannelSpec(spec: ChannelEnsureInput): void {
135
160
  this.channelSpecs.set(spec.taskId, spec);
161
+ // ADR-049: the cloud re-sends the spec on every message AND right after a
162
+ // mid-task roster/participant change. If the channel is already live,
163
+ // fold the fresh spec in: append new roster agents (their sessions spawn
164
+ // in the reconcile pass of ensureSessions) and rebuild every preamble so
165
+ // a later respawn briefs agents with the CURRENT roster + humans.
166
+ const channel = this.channels.get(spec.taskId);
167
+ if (channel) this.refreshChannel(channel, spec);
168
+ }
169
+
170
+ /** Fold a fresh channel spec into a live channel (ADR-049). */
171
+ private refreshChannel(channel: Channel, spec: ChannelEnsureInput): void {
172
+ const known = new Set(channel.roster.map((a) => a.id));
173
+ for (const agent of spec.agents) {
174
+ if (!known.has(agent.id)) {
175
+ channel.roster.push(agent);
176
+ // Per-agent link/document skills, like getOrCreateChannel does at
177
+ // channel birth. Best-effort.
178
+ writeAgentSkills(channel.taskId, agent);
179
+ }
180
+ }
181
+ channel.humans = spec.humans ?? [];
182
+ channel.browserTesting = spec.browserTesting === true;
183
+ channel.mcpConnections = spec.mcpConnections ?? [];
184
+ for (const agent of channel.roster) {
185
+ channel.preambles.set(
186
+ agent.id,
187
+ buildSystemPreamble(
188
+ channel.roster,
189
+ agent,
190
+ spec.projects,
191
+ spec.globalContext,
192
+ spec.workspacePath,
193
+ spec.branch,
194
+ channel.humans,
195
+ channel.browserTesting,
196
+ ),
197
+ );
198
+ }
136
199
  }
137
200
 
138
201
  private async getOrCreateChannel(taskId: string): Promise<Channel | null> {
@@ -155,6 +218,8 @@ class Orchestrator {
155
218
  spec.globalContext,
156
219
  spec.workspacePath,
157
220
  spec.branch,
221
+ spec.humans,
222
+ spec.browserTesting,
158
223
  ),
159
224
  );
160
225
  // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
@@ -181,6 +246,11 @@ class Orchestrator {
181
246
  openTurns: new Set(),
182
247
  interrupted: new Set(),
183
248
  respawns: new Map(),
249
+ respawnLastAt: new Map(),
250
+ humans: spec.humans ?? [],
251
+ browserTesting: spec.browserTesting === true,
252
+ mcpConnections: spec.mcpConnections ?? [],
253
+ spawning: new Set(),
184
254
  };
185
255
  this.channels.set(taskId, channel);
186
256
  return channel;
@@ -197,7 +267,7 @@ class Orchestrator {
197
267
  *
198
268
  * Returns whether sessions are ready.
199
269
  */
200
- private ensureSessions(channel: Channel): Promise<boolean> {
270
+ private async ensureSessions(channel: Channel): Promise<boolean> {
201
271
  // Memoized: every caller awaits the SAME in-flight start, so a concurrent
202
272
  // deliver() can't observe "ready" while the sessions map is still empty
203
273
  // (startSessions awaits docker work before populating it — the old boolean
@@ -210,12 +280,107 @@ class Orchestrator {
210
280
  (ok) => {
211
281
  if (!ok) channel.sessionsReady = null;
212
282
  },
213
- () => {
283
+ (err: unknown) => {
284
+ // A start failure retries on the next ensure — but it must be
285
+ // VISIBLE: an unlogged throw here once looped silently every ~2s
286
+ // while a task sat dead with no sessions and no trace.
287
+ console.warn(
288
+ `[orchestrator] ${channel.taskId}: session start failed: ` +
289
+ `${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
290
+ );
214
291
  channel.sessionsReady = null;
215
292
  },
216
293
  );
217
294
  }
218
- return channel.sessionsReady;
295
+ const ready = await channel.sessionsReady;
296
+ // ADR-049 reconcile pass: spawn any roster agent that has no live session
297
+ // yet — the initial batch, host-restart recovery, and mid-task adds all
298
+ // converge here. No-op when every roster agent has a session.
299
+ if (ready) {
300
+ await this.reconcileSessions(channel);
301
+ // ADR-057: keep the container's MCP configs current so a connection
302
+ // added mid-task lands (effective at each session's next spawn).
303
+ await this.ensureMcpConfig(channel);
304
+ }
305
+ return ready;
306
+ }
307
+
308
+ /** Write the task's MCP configs when the connection set changed. */
309
+ private async ensureMcpConfig(channel: Channel): Promise<void> {
310
+ const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
311
+ if (channel.mcpConfigFingerprint === fingerprint) return;
312
+ channel.mcpConfigFingerprint = fingerprint;
313
+ await setupMcpTaskConfig(
314
+ channel.taskId,
315
+ channel.containerName,
316
+ channel.mcpConnections,
317
+ channel.roster.some((a) => a.kind === "codex"),
318
+ );
319
+ }
320
+
321
+ /** Spawn sessions for roster agents added after the initial start. */
322
+ private async reconcileSessions(channel: Channel): Promise<void> {
323
+ const missing = channel.roster.filter((agent) => {
324
+ if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
325
+ return false;
326
+ }
327
+ // Crash-loop budget: an agent whose session keeps dying stops being
328
+ // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment) —
329
+ // but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
330
+ // the task heals itself once the cause (a missing CLI on the shared
331
+ // volume, a broken config) is fixed, instead of staying dead until a
332
+ // host restart.
333
+ if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
334
+ const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
335
+ if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
336
+ channel.respawns.set(agent.id, 0);
337
+ }
338
+ return true;
339
+ });
340
+ if (missing.length === 0) return;
341
+
342
+ const task = getHostTask(channel.taskId);
343
+ if (!task || task.statusMirror !== "running") return;
344
+
345
+ for (const agent of missing) channel.spawning.add(agent.id);
346
+ try {
347
+ // Same per-agent materialisation the initial start does: package
348
+ // skills (idempotent — only the new agents' installs run), and the
349
+ // shared `uai` CLI rewritten for the full roster.
350
+ await installPackageSkills(channel.taskId, missing);
351
+ if (channel.browserTesting) {
352
+ await setupBrowserTesting(
353
+ channel.taskId,
354
+ channel.containerName,
355
+ channel.roster.some((a) => a.kind === "codex"),
356
+ );
357
+ }
358
+ const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
359
+ const cliSecret = loadTaskCliSecret(channel.taskId);
360
+ writeAgentCli(channel.taskId, channel.roster, apiUrl);
361
+
362
+ for (const agent of missing) {
363
+ const session = await this.factory.create({
364
+ taskId: channel.taskId,
365
+ agent,
366
+ containerName: channel.containerName,
367
+ systemPreamble: channel.preambles.get(agent.id) ?? "",
368
+ agentEnv: agentCliEnv(
369
+ channel.taskId,
370
+ agent,
371
+ task.ownerUserId,
372
+ apiUrl,
373
+ cliSecret,
374
+ ),
375
+ });
376
+ channel.sessions.set(agent.id, session);
377
+ session.onEvent((event) => {
378
+ void this.handleAgentEvent(channel, agent.id, event);
379
+ });
380
+ }
381
+ } finally {
382
+ for (const agent of missing) channel.spawning.delete(agent.id);
383
+ }
219
384
  }
220
385
 
221
386
  private async startSessions(channel: Channel): Promise<boolean> {
@@ -246,6 +411,21 @@ class Orchestrator {
246
411
  // tasks. Never throws.
247
412
  await installPackageSkills(channel.taskId, channel.roster);
248
413
 
414
+ // ADR-053: wire the Playwright MCP browser (configs + backgrounded
415
+ // Chromium install) before agents spawn. Idempotent + best-effort.
416
+ if (channel.browserTesting) {
417
+ await setupBrowserTesting(
418
+ channel.taskId,
419
+ channel.containerName,
420
+ channel.roster.some((a) => a.kind === "codex"),
421
+ );
422
+ }
423
+
424
+ // ADR-057: the owner's MCP connections, reached through the host gateway
425
+ // (tokens never enter the container) — written BEFORE agents spawn so
426
+ // first sessions load them.
427
+ await this.ensureMcpConfig(channel);
428
+
249
429
  // ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
250
430
  // workspace. Each agent's OWN task token — carrying only ITS permissions — is
251
431
  // injected per-agent via its docker exec env below, so per-persona permissions
@@ -449,6 +629,15 @@ class Orchestrator {
449
629
  agentId,
450
630
  reason: event.message,
451
631
  });
632
+ // The session is DEAD — drop it so the reconciling ensureSessions
633
+ // respawns it on the next delivery/ensure. Without this the agent
634
+ // becomes a zombie: the map still holds the dead session and every
635
+ // later deliver "succeeds" into a closed pipe (found live 2026-07-08
636
+ // after a double SIGKILL). Bounded by the respawn budget, checked in
637
+ // reconcileSessions.
638
+ channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
639
+ channel.respawnLastAt.set(agentId, Date.now());
640
+ channel.sessions.delete(agentId);
452
641
  break;
453
642
  }
454
643
  case "turn_complete": {
@@ -467,6 +656,11 @@ class Orchestrator {
467
656
  break;
468
657
  }
469
658
  case "exit":
659
+ // Same zombie hazard as the error path — a session whose process
660
+ // ended (even cleanly) can never carry another turn.
661
+ channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
662
+ channel.respawnLastAt.set(agentId, Date.now());
663
+ channel.sessions.delete(agentId);
470
664
  break;
471
665
  }
472
666
  }
@@ -484,6 +678,7 @@ class Orchestrator {
484
678
  ): Promise<void> {
485
679
  const tries = (channel.respawns.get(agentId) ?? 0) + 1;
486
680
  channel.respawns.set(agentId, tries);
681
+ channel.respawnLastAt.set(agentId, Date.now());
487
682
 
488
683
  if (tries > MAX_RESPAWNS_PER_AGENT) {
489
684
  this.emitHost({
@@ -754,12 +949,49 @@ export function buildSystemPreamble(
754
949
  globalContext: string | undefined,
755
950
  workspacePath: string,
756
951
  taskBranch: string,
952
+ humans?: ChannelHuman[],
953
+ browserTesting?: boolean,
757
954
  ): string {
758
955
  const channelList = roster
759
956
  .map((a) =>
760
957
  a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
761
958
  )
762
959
  .join(", ");
960
+ // ADR-049: with several humans in the chat, brief the agent on who they are
961
+ // and how to address one specifically. Single-human tasks keep the original
962
+ // wording byte-identical.
963
+ const multiHuman = (humans?.length ?? 0) > 1;
964
+ const humanList = (humans ?? [])
965
+ .map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
966
+ .join(", ");
967
+ const humanIntro = multiHuman
968
+ ? [
969
+ `SEVERAL humans share this channel: ${humanList}. Their messages`,
970
+ "arrive prefixed with the sender's name so you can tell them apart.",
971
+ "`@you` still works and reaches the human you're currently talking",
972
+ "to (whoever last addressed you); to reach a SPECIFIC human, mention",
973
+ "their handle instead (e.g. `@" +
974
+ (humans?.[0]?.handle ?? "name") +
975
+ "`). Mentioning a human sends a",
976
+ "NOTIFICATION, so use it sparingly — only when you actually need",
977
+ "them: a decision you can't make, a blocker, an approval, or you've",
978
+ "finished your work and are handing it back. For everything else —",
979
+ "status updates, thinking out loud, a direct reply to something they",
980
+ "just asked, acknowledgments — post in the channel WITHOUT",
981
+ "@-mentioning; they can read the channel and don't need a ping for",
982
+ "every message.",
983
+ ]
984
+ : [
985
+ "The human you're working with is **@you**. @-mentioning them sends a",
986
+ "NOTIFICATION, so use it sparingly — only when you actually need them: a",
987
+ "decision you can't make, a blocker, an approval, or you've finished your",
988
+ "work and are handing it back for them to act on (e.g. `@you which`",
989
+ "`approach do you prefer?` or `@you done — PR is up for review`). For",
990
+ "everything else — status updates, thinking out loud, a direct reply to",
991
+ "something they just asked, acknowledgments — post in the channel WITHOUT",
992
+ "@-mentioning @you; they can read the channel and don't need a ping for",
993
+ "every message. Do NOT reflexively end messages with @you.",
994
+ ];
763
995
  const projectLines =
764
996
  projects.length === 0
765
997
  ? ["(none mounted)"]
@@ -781,15 +1013,7 @@ export function buildSystemPreamble(
781
1013
  "",
782
1014
  `Agents in this channel: ${channelList}.`,
783
1015
  "",
784
- "The human you're working with is **@you**. @-mentioning them sends a",
785
- "NOTIFICATION, so use it sparingly — only when you actually need them: a",
786
- "decision you can't make, a blocker, an approval, or you've finished your",
787
- "work and are handing it back for them to act on (e.g. `@you which`",
788
- "`approach do you prefer?` or `@you done — PR is up for review`). For",
789
- "everything else — status updates, thinking out loud, a direct reply to",
790
- "something they just asked, acknowledgments — post in the channel WITHOUT",
791
- "@-mentioning @you; they can read the channel and don't need a ping for",
792
- "every message. Do NOT reflexively end messages with @you.",
1016
+ ...humanIntro,
793
1017
  "",
794
1018
  "An agent only receives a message when it is explicitly @-mentioned",
795
1019
  "(or addressed by the human) — so always @-mention the agent (or @you)",
@@ -827,6 +1051,15 @@ export function buildSystemPreamble(
827
1051
  "context (e.g. the human shared a file or instruction with another",
828
1052
  "agent); it's appended live, so re-read it for the latest.",
829
1053
  "",
1054
+ "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
1055
+ "uai hands it back to whoever prompted you — so when you're ANSWERING,",
1056
+ "just answer plainly; you don't need to re-mention the asker. Mention",
1057
+ "someone only to bring them in or hand work off. (2) You may occasionally",
1058
+ "receive a `[channel check-in]` asking you to catch up on the channel.",
1059
+ "Read the transcript, and speak ONLY if you have something substantive to",
1060
+ "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
1061
+ "never shown to anyone, so it is always a safe way to decline a turn.",
1062
+ "",
830
1063
  "Hand off when you finish your part of the work. When you've made",
831
1064
  "and committed your changes, or completed a review, end your reply by",
832
1065
  "@-mentioning the agent who should act next and telling them what you",
@@ -886,6 +1119,28 @@ export function buildSystemPreamble(
886
1119
  "",
887
1120
  ]
888
1121
  : []),
1122
+ // ADR-053: the in-container browser, when the project opted in.
1123
+ ...(browserTesting
1124
+ ? [
1125
+ "## Browser",
1126
+ "",
1127
+ "This container has a headless Chromium available through the",
1128
+ "`browser` MCP server (Playwright). Use it to VERIFY UI work",
1129
+ "end-to-end — the dev server you're building runs in this same",
1130
+ "container, so navigate to `http://localhost:<port>` directly.",
1131
+ "Prefer accessibility snapshots for navigation and assertions;",
1132
+ "take an actual screenshot only when rendering matters (vision",
1133
+ "input is expensive). To show a screenshot in the chat, save it",
1134
+ "under `/workspace/.uai/attachments/<name>.png` and reference it",
1135
+ "in your reply as `[image: /workspace/.uai/attachments/<name>.png]`.",
1136
+ "Your browser runs on a virtual display the humans can WATCH live",
1137
+ "(the \"browser\" preview) — nothing for you to do about that.",
1138
+ "The first browser launch in a fresh container may take a minute",
1139
+ "while Chromium's system deps finish installing — retry once if",
1140
+ "it fails immediately after task start.",
1141
+ "",
1142
+ ]
1143
+ : []),
889
1144
  // ADR-048: tell agents with permissions about their `uai` CLI.
890
1145
  ...((agent.permissions?.length ?? 0) > 0
891
1146
  ? [
@@ -904,7 +1159,12 @@ export function buildSystemPreamble(
904
1159
  (agent.permissions?.includes("memory.read")
905
1160
  ? ", `memory search <query>`"
906
1161
  : "") +
907
- ".",
1162
+ ", `react <heart|check|x> [--msg #id]`.",
1163
+ "**Reacting:** `uai react check` is a lightweight ack of the message",
1164
+ "you were last handed — use it to acknowledge an instruction or",
1165
+ "approve a proposal without spending a whole reply (❌ = disagree,",
1166
+ "❤️ = appreciation). Messages in chat.md carry their `#id` if you",
1167
+ "want to react to an older one. Reactions wake nobody.",
908
1168
  ...(agent.permissions?.includes("tasks.create")
909
1169
  ? [
910
1170
  "**Creating a task in Uai:** when a human asks you to create/file/",
@@ -17,7 +17,9 @@
17
17
  */
18
18
 
19
19
  import { spawn } from "node:child_process";
20
- import { dirname, resolve } from "node:path";
20
+ import { createHash } from "node:crypto";
21
+ import { readdir, readFile } from "node:fs/promises";
22
+ import { dirname, join, resolve } from "node:path";
21
23
  import { fileURLToPath } from "node:url";
22
24
 
23
25
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
@@ -57,6 +59,40 @@ export function standardRuntimes(): Array<{
57
59
  }));
58
60
  }
59
61
 
62
+ /** Image label carrying the build-context content hash (rebuild trigger). */
63
+ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
64
+
65
+ /**
66
+ * Content-hash the build context (every file under images/standard, sorted
67
+ * by relative path). Null when the context can't be read — the caller then
68
+ * keeps whatever image exists.
69
+ */
70
+ async function hashBuildContext(): Promise<string | null> {
71
+ try {
72
+ const root = standardImageDir();
73
+ const files: string[] = [];
74
+ const walk = async (dir: string, prefix: string): Promise<void> => {
75
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
76
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
77
+ if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
78
+ else files.push(rel);
79
+ }
80
+ };
81
+ await walk(root, "");
82
+ files.sort();
83
+ const hash = createHash("sha256");
84
+ for (const rel of files) {
85
+ hash.update(rel);
86
+ hash.update("\0");
87
+ hash.update(await readFile(join(root, rel)));
88
+ hash.update("\0");
89
+ }
90
+ return hash.digest("hex").slice(0, 32);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
60
96
  /** Absolute path to the standard image build context. */
61
97
  function standardImageDir(): string {
62
98
  const here = dirname(fileURLToPath(import.meta.url));
@@ -177,6 +213,65 @@ async function ensureVolumeAgentClis(): Promise<void> {
177
213
  }
178
214
  }
179
215
 
216
+ /**
217
+ * Upgrade the shared volume's agent CLIs to latest — once per HOST START, not
218
+ * per task. Per-task installs would add npm latency to every task-up, race
219
+ * concurrent starts on the shared volume, and give a broken vendor release a
220
+ * fleet-wide blast radius with no rollback; host restarts are the deliberate
221
+ * update moment (sessions respawn then anyway, and a bad release is one
222
+ * `UAI_AGENT_CLI_AUTOUPDATE=0` + manual pin away from contained). Running
223
+ * sessions keep their already-loaded process; new spawns pick up the new bin.
224
+ * Best-effort: an offline registry just logs and keeps the current versions.
225
+ */
226
+ async function upgradeVolumeAgentClis(): Promise<void> {
227
+ if (process.env.UAI_AGENT_CLI_AUTOUPDATE === "0") {
228
+ console.log("[host-agent] agent CLI auto-update disabled (UAI_AGENT_CLI_AUTOUPDATE=0)");
229
+ return;
230
+ }
231
+ const pkgs = VOLUME_AGENT_CLIS.map((c) => `${c.pkg}@latest`).join(" ");
232
+ const bins = VOLUME_AGENT_CLIS.map((c) => c.bin);
233
+ // npm's in-place upgrade renames the old package dir, which fails with
234
+ // ENOTEMPTY while (or right after) live sessions held it open through the
235
+ // volume. Fallback: remove the scoped dirs and install fresh — safe at host
236
+ // start, when the previous sessions' execs are gone.
237
+ const scopeDirs = "/opt/asdf-data/installs/nodejs/*/lib/node_modules/@anthropic-ai /opt/asdf-data/installs/nodejs/*/lib/node_modules/@openai";
238
+ const upgrade = await run("docker", [
239
+ "run",
240
+ "--rm",
241
+ // The container NAME is the mutex: overlapping boots (or a crash-looping
242
+ // service) must never race two npm installs on the shared volume — that
243
+ // once left it with no `claude` at all (2026-07-13). Docker rejects the
244
+ // duplicate name; we treat that as "already upgrading, skip".
245
+ "--name",
246
+ "uai-cli-upgrade",
247
+ "-u",
248
+ "root",
249
+ "-w",
250
+ "/home/node",
251
+ "-v",
252
+ `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
253
+ STANDARD_IMAGE_TAG,
254
+ "bash",
255
+ "-lc",
256
+ `npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; } && asdf reshim nodejs >/dev/null 2>&1; ` +
257
+ bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
258
+ ]);
259
+ if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
260
+ console.log("[host-agent] agent CLI upgrade already running — skipped");
261
+ return;
262
+ }
263
+ if (upgrade.code === 0) {
264
+ console.log(
265
+ `[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
266
+ );
267
+ } else {
268
+ console.warn(
269
+ `[host-agent] agent CLI upgrade skipped (exit ${upgrade.code ?? "spawn"}) — ` +
270
+ `tasks keep the volume's current versions. ${upgrade.stderr.trim().slice(0, 200)}`,
271
+ );
272
+ }
273
+ }
274
+
180
275
  /**
181
276
  * Ensure the standard image and the shared asdf data volume exist, and that the
182
277
  * agent CLIs are present inside the volume. Builds the image only when `docker
@@ -196,24 +291,51 @@ export async function ensureStandardImage(): Promise<void> {
196
291
  if (vol.code === null) return;
197
292
  }
198
293
 
199
- // 2. Ensure the image is built.
294
+ // 2. Ensure the image is built AND current. "Present" is not enough — a
295
+ // Dockerfile change used to no-op forever because we only built when
296
+ // inspect failed (found live 2026-07-08: ADR-053's baked packages never
297
+ // landed). The build context is content-hashed into an image label;
298
+ // a mismatch triggers a rebuild (layer cache keeps it cheap).
200
299
  let imageReady = false;
201
- const inspect = await run("docker", ["image", "inspect", STANDARD_IMAGE_TAG]);
202
- if (inspect.code === 0) {
203
- console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
204
- imageReady = true;
205
- } else if (inspect.code === null) {
300
+ const contextHash = await hashBuildContext();
301
+ const inspect = await run("docker", [
302
+ "image",
303
+ "inspect",
304
+ "-f",
305
+ `{{index .Config.Labels "${CONTEXT_HASH_LABEL}"}}`,
306
+ STANDARD_IMAGE_TAG,
307
+ ]);
308
+ if (inspect.code === null) {
206
309
  console.warn(
207
310
  "[host-agent] docker unavailable; skipping standard image build. " +
208
311
  "Tasks will fail until docker is running.",
209
312
  );
210
313
  return;
314
+ }
315
+ const labeledHash = inspect.code === 0 ? inspect.stdout.trim() : null;
316
+ if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
317
+ console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
318
+ imageReady = true;
319
+ } else if (inspect.code === 0 && contextHash === null) {
320
+ // Can't hash (packaged install without the context?) — keep the image.
321
+ console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
322
+ imageReady = true;
211
323
  } else {
212
324
  const context = standardImageDir();
213
325
  console.log(
214
- `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
326
+ inspect.code === 0
327
+ ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
328
+ : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
215
329
  );
216
- const build = await run("docker", ["build", "-t", STANDARD_IMAGE_TAG, context]);
330
+ const build = await run("docker", [
331
+ "build",
332
+ "-t",
333
+ STANDARD_IMAGE_TAG,
334
+ ...(contextHash !== null
335
+ ? ["--label", `${CONTEXT_HASH_LABEL}=${contextHash}`]
336
+ : []),
337
+ context,
338
+ ]);
217
339
  if (build.code === 0) {
218
340
  console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
219
341
  imageReady = true;
@@ -223,10 +345,15 @@ export async function ensureStandardImage(): Promise<void> {
223
345
  "continuing. Tasks needing the image will surface this error.\n" +
224
346
  build.stderr.trim(),
225
347
  );
348
+ // A stale-but-working image is better than none.
349
+ imageReady = labeledHash !== null;
226
350
  }
227
351
  }
228
352
 
229
353
  // 3. Reconcile the agent CLIs into the shared volume (it shadows the image's
230
354
  // shims, so a stale volume can be missing one — the claude-127 bug).
231
- if (imageReady) await ensureVolumeAgentClis();
355
+ if (imageReady) {
356
+ await ensureVolumeAgentClis();
357
+ await upgradeVolumeAgentClis();
358
+ }
232
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.4.2",
3
+ "version": "0.5.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>",
@@ -40,6 +40,7 @@
40
40
  "db",
41
41
  "scripts/agent",
42
42
  "scripts/install",
43
+ "runner",
43
44
  "images/standard",
44
45
  "ui",
45
46
  "README.md",