@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.
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +15 -1
- package/db/schema.ts +61 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +6 -0
- package/lib/agents/claude.ts +22 -16
- package/lib/agents/codex.ts +36 -22
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +554 -0
- package/lib/mcp-gateway.ts +342 -0
- package/lib/orchestrator.ts +274 -14
- package/lib/standard-image.ts +137 -10
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +10 -0
- package/src/index.ts +52 -1
- package/src/main.ts +102 -0
- package/src/protocol.ts +82 -2
package/lib/orchestrator.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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/",
|
package/lib/standard-image.ts
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import {
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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", [
|
|
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)
|
|
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.
|
|
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",
|