@runuai/host 0.2.8 → 0.4.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/lib/agent-cli.ts +223 -0
- package/lib/agents/claude.ts +12 -2
- package/lib/agents/codex.ts +16 -2
- package/lib/agents/proc.ts +9 -0
- package/lib/agents/registry.ts +11 -1
- package/lib/agents/types.ts +18 -0
- package/lib/command-db.ts +6 -2
- package/lib/git-diff.ts +67 -16
- package/lib/orchestrator.ts +153 -18
- package/lib/preview-sidecar.ts +157 -0
- package/lib/skills.ts +263 -0
- package/lib/task-diff.ts +75 -20
- package/lib/task-token.ts +40 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +22 -26
- package/src/index.ts +6 -0
- package/src/main.ts +149 -22
- package/src/protocol.ts +27 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -37,6 +37,20 @@ import { getHostTask, upsertHostTask } from "./runtime-state";
|
|
|
37
37
|
import { setupTaskGithub } from "./github-tokens";
|
|
38
38
|
import { setupTaskGitIdentity } from "./git-identity";
|
|
39
39
|
import { dockerCli } from "./docker-exec";
|
|
40
|
+
import {
|
|
41
|
+
containerSkillFile,
|
|
42
|
+
installedSkillPath,
|
|
43
|
+
installPackageSkills,
|
|
44
|
+
writeAgentSkills,
|
|
45
|
+
} from "./skills";
|
|
46
|
+
import {
|
|
47
|
+
CONTAINER_CLI_PATH,
|
|
48
|
+
agentCliEnv,
|
|
49
|
+
apiUrlFromCloudUrl,
|
|
50
|
+
loadTaskCliSecret,
|
|
51
|
+
writeAgentCli,
|
|
52
|
+
} from "./agent-cli";
|
|
53
|
+
import { env } from "./env";
|
|
40
54
|
import type { ChannelEnsureInput, HostEvent } from "../src/protocol";
|
|
41
55
|
|
|
42
56
|
export type HostEventSubscriber = (event: HostEvent) => void;
|
|
@@ -143,6 +157,9 @@ class Orchestrator {
|
|
|
143
157
|
spec.branch,
|
|
144
158
|
),
|
|
145
159
|
);
|
|
160
|
+
// ADR-046: materialise this agent's skills to its per-agent SKILL.md in
|
|
161
|
+
// the workspace (bind-mounted into the container). Best-effort.
|
|
162
|
+
writeAgentSkills(taskId, agent);
|
|
146
163
|
// ADR-022: any agent with a non-empty initialPrompt opens a first
|
|
147
164
|
// turn at container-ready. The rest stay silent until addressed.
|
|
148
165
|
const firstTurn = assembleFirstTurnPrompt(
|
|
@@ -222,12 +239,28 @@ class Orchestrator {
|
|
|
222
239
|
void setupTaskGithub(channel.taskId, task.ownerUserId);
|
|
223
240
|
}
|
|
224
241
|
|
|
242
|
+
// ADR-047: install any package skills (native Claude Agent Skills) into the
|
|
243
|
+
// container BEFORE spawning agents, so they're discoverable on the first
|
|
244
|
+
// turn. Idempotent + best-effort (returns fast when there are none); a slow
|
|
245
|
+
// clone/install briefly delays start, which is acceptable for skill-bearing
|
|
246
|
+
// tasks. Never throws.
|
|
247
|
+
await installPackageSkills(channel.taskId, channel.roster);
|
|
248
|
+
|
|
249
|
+
// ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
|
|
250
|
+
// workspace. Each agent's OWN task token — carrying only ITS permissions — is
|
|
251
|
+
// injected per-agent via its docker exec env below, so per-persona permissions
|
|
252
|
+
// are actually enforced. Best-effort, host-side.
|
|
253
|
+
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
254
|
+
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
255
|
+
writeAgentCli(channel.taskId, channel.roster, apiUrl);
|
|
256
|
+
|
|
225
257
|
for (const agent of channel.roster) {
|
|
226
258
|
const session = await this.factory.create({
|
|
227
259
|
taskId: channel.taskId,
|
|
228
260
|
agent,
|
|
229
261
|
containerName: channel.containerName,
|
|
230
262
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
263
|
+
agentEnv: agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
|
|
231
264
|
});
|
|
232
265
|
channel.sessions.set(agent.id, session);
|
|
233
266
|
session.onEvent((event) => {
|
|
@@ -235,14 +268,13 @@ class Orchestrator {
|
|
|
235
268
|
});
|
|
236
269
|
}
|
|
237
270
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
271
|
+
// First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
|
|
272
|
+
// which fires each agent's initialPrompt exactly once per task — guarded by
|
|
273
|
+
// a non-empty message history (its durable "already started" check). The
|
|
274
|
+
// host must NOT deliver it here: this path re-runs on every session spawn,
|
|
275
|
+
// so it both duplicated the opening turn AND re-injected it on a host
|
|
276
|
+
// restart, making agents redo the first turn mid-task. The persona/mission
|
|
277
|
+
// an agent needs live in the always-on system preamble, so nothing is lost.
|
|
246
278
|
return true;
|
|
247
279
|
}
|
|
248
280
|
|
|
@@ -723,7 +755,11 @@ export function buildSystemPreamble(
|
|
|
723
755
|
workspacePath: string,
|
|
724
756
|
taskBranch: string,
|
|
725
757
|
): string {
|
|
726
|
-
const
|
|
758
|
+
const channelList = roster
|
|
759
|
+
.map((a) =>
|
|
760
|
+
a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
|
|
761
|
+
)
|
|
762
|
+
.join(", ");
|
|
727
763
|
const projectLines =
|
|
728
764
|
projects.length === 0
|
|
729
765
|
? ["(none mounted)"]
|
|
@@ -739,19 +775,51 @@ export function buildSystemPreamble(
|
|
|
739
775
|
"it by id at the start of a line — e.g. `@codex please review the",
|
|
740
776
|
"diff`. uai routes that message into that agent's input.",
|
|
741
777
|
"",
|
|
742
|
-
|
|
778
|
+
`**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
|
|
779
|
+
`Introduce and refer to yourself as ${agent.label}, not as a generic`,
|
|
780
|
+
"assistant or model name.",
|
|
781
|
+
"",
|
|
782
|
+
`Agents in this channel: ${channelList}.`,
|
|
743
783
|
"",
|
|
744
|
-
"The human you're working with is **@you**.
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
"
|
|
748
|
-
"`prefer?` or `@you done — PR is up for review`).
|
|
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.",
|
|
749
793
|
"",
|
|
750
794
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
751
795
|
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
752
796
|
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
753
797
|
"hand-offs are just @-mentions in your replies.",
|
|
754
798
|
"",
|
|
799
|
+
"Collaborate with your peers — divide up the work, review each other's",
|
|
800
|
+
"changes, share concrete ideas, and debate approach decisions by",
|
|
801
|
+
"@-mentioning them. That is how the team gets things done, and you",
|
|
802
|
+
"should do it freely whenever it moves the work forward. But mentioning",
|
|
803
|
+
"a peer WAKES it and costs a turn, so make each one count: a message to",
|
|
804
|
+
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
805
|
+
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
806
|
+
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
807
|
+
"you're ready — content-free replies wake them for nothing and spiral",
|
|
808
|
+
"into endless back-and-forth. If you have nothing substantive to add,",
|
|
809
|
+
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
810
|
+
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
811
|
+
"need to @-mention anyone (including @you); they can see the channel.",
|
|
812
|
+
"",
|
|
813
|
+
"When a message already @-mentions several participants at once (the",
|
|
814
|
+
"human asking the whole group, or a peer addressing multiple agents),",
|
|
815
|
+
"it's a group broadcast — this is a GROUP CHAT and everyone named has",
|
|
816
|
+
"ALREADY been notified and will answer for themselves. Just answer for",
|
|
817
|
+
"YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
|
|
818
|
+
"question to them, or wait on them — no `I'll let @x speak`, `@x your",
|
|
819
|
+
"turn`, or `still waiting on @x`. Re-mentioning someone who already got",
|
|
820
|
+
"the message only wakes them again and spirals into duplicate replies.",
|
|
821
|
+
"Say your piece and stop.",
|
|
822
|
+
"",
|
|
755
823
|
"Because your input is only what you're addressed, you may be missing",
|
|
756
824
|
"context from messages between the human and the other agents. The full",
|
|
757
825
|
"channel transcript — every message + who wrote it (no tool calls) — is",
|
|
@@ -763,9 +831,11 @@ export function buildSystemPreamble(
|
|
|
763
831
|
"and committed your changes, or completed a review, end your reply by",
|
|
764
832
|
"@-mentioning the agent who should act next and telling them what you",
|
|
765
833
|
"did and what you need (e.g. `@codex changes committed on <branch> —",
|
|
766
|
-
"please review`, or `@claude review done, N issues to fix`).
|
|
767
|
-
"
|
|
768
|
-
"
|
|
834
|
+
"please review`, or `@claude review done, N issues to fix`). Don't",
|
|
835
|
+
"abandon unfinished work silently — but once your part is done and no",
|
|
836
|
+
"peer needs to act, it's fine to stop; only @-mention @you if you need",
|
|
837
|
+
"their input or are handing back finished work for them to act on. Don't",
|
|
838
|
+
"prolong an agent-to-agent exchange just to fill silence.",
|
|
769
839
|
"",
|
|
770
840
|
"## Workspace layout",
|
|
771
841
|
"",
|
|
@@ -788,6 +858,71 @@ export function buildSystemPreamble(
|
|
|
788
858
|
"part of the project. Never review, edit, stage, commit, or flag it;",
|
|
789
859
|
"treat it as ignored, even though git may show it as untracked.",
|
|
790
860
|
"",
|
|
861
|
+
// ADR-046: link/document skills are materialised to a per-agent SKILL.md.
|
|
862
|
+
// Point the agent at its own file (package skills are handled below).
|
|
863
|
+
...((agent.skills ?? []).some((s) => s.type !== "package")
|
|
864
|
+
? [
|
|
865
|
+
"## Your skills",
|
|
866
|
+
"",
|
|
867
|
+
"Reference material (links + documents) has been prepared for you at",
|
|
868
|
+
`\`${containerSkillFile(agent.id)}\`. Read it when a task touches what`,
|
|
869
|
+
"it covers. It's yours — other agents have their own.",
|
|
870
|
+
"",
|
|
871
|
+
]
|
|
872
|
+
: []),
|
|
873
|
+
// ADR-047: package skills are native Claude Agent Skills installed into the
|
|
874
|
+
// container's skills dir (Claude-only). List them so the agent knows they're
|
|
875
|
+
// available even if headless auto-discovery is unreliable.
|
|
876
|
+
...(agent.kind === "claude" &&
|
|
877
|
+
(agent.skills ?? []).some((s) => s.type === "package")
|
|
878
|
+
? [
|
|
879
|
+
"## Installed skills",
|
|
880
|
+
"",
|
|
881
|
+
"These Claude Agent Skills are installed for this task — invoke them",
|
|
882
|
+
"when a task touches what they cover:",
|
|
883
|
+
...(agent.skills ?? [])
|
|
884
|
+
.filter((s) => s.type === "package")
|
|
885
|
+
.map((s) => `- **${s.name}** (\`${installedSkillPath(s)}\`)`),
|
|
886
|
+
"",
|
|
887
|
+
]
|
|
888
|
+
: []),
|
|
889
|
+
// ADR-048: tell agents with permissions about their `uai` CLI.
|
|
890
|
+
...((agent.permissions?.length ?? 0) > 0
|
|
891
|
+
? [
|
|
892
|
+
"## The uai CLI",
|
|
893
|
+
"",
|
|
894
|
+
`You can query and plan through Uai by running \`node ${CONTAINER_CLI_PATH}`,
|
|
895
|
+
"<command>` in the shell. Your permissions:",
|
|
896
|
+
`\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
|
|
897
|
+
"`people list`, `task list`" +
|
|
898
|
+
(agent.permissions?.includes("tasks.create")
|
|
899
|
+
? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
|
|
900
|
+
: "") +
|
|
901
|
+
(agent.permissions?.includes("memory.write")
|
|
902
|
+
? ", `memory save <text>`"
|
|
903
|
+
: "") +
|
|
904
|
+
(agent.permissions?.includes("memory.read")
|
|
905
|
+
? ", `memory search <query>`"
|
|
906
|
+
: "") +
|
|
907
|
+
".",
|
|
908
|
+
...(agent.permissions?.includes("tasks.create")
|
|
909
|
+
? [
|
|
910
|
+
"Tasks you create are DRAFTS — a human reviews and starts them;",
|
|
911
|
+
"nothing runs (or spends) until then.",
|
|
912
|
+
]
|
|
913
|
+
: []),
|
|
914
|
+
...(agent.permissions?.includes("memory.read") ||
|
|
915
|
+
agent.permissions?.includes("memory.write")
|
|
916
|
+
? [
|
|
917
|
+
`Your memory handle is \`${agent.id}\` — pass \`--as ${agent.id}\` so`,
|
|
918
|
+
"memories are attributed to you. Recall relevant past work with",
|
|
919
|
+
"`memory search` before you start; save durable learnings with",
|
|
920
|
+
"`memory save` when you finish something worth remembering.",
|
|
921
|
+
]
|
|
922
|
+
: []),
|
|
923
|
+
"",
|
|
924
|
+
]
|
|
925
|
+
: []),
|
|
791
926
|
"## Commit policy",
|
|
792
927
|
"",
|
|
793
928
|
"Commits are SSH-signed automatically (git is configured for it) — do",
|
|
@@ -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/skills.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent skills materialisation (ADR-046).
|
|
3
|
+
*
|
|
4
|
+
* A task agent's skills (reference links + documents, resolved cloud-side from
|
|
5
|
+
* its persona + team) are written to a per-agent `SKILL.md` inside the task
|
|
6
|
+
* workspace. The workspace is bind-mounted into the container at `/workspace`
|
|
7
|
+
* (ADR-014), so writing on the host makes the file appear in the container —
|
|
8
|
+
* no `docker cp`/`exec` needed (same mechanism as attachments).
|
|
9
|
+
*
|
|
10
|
+
* Each agent gets its OWN file under `.uai/agents/<id>/` so agents in a shared
|
|
11
|
+
* container don't see each other's material; the agent's system preamble points
|
|
12
|
+
* at its file (buildSystemPreamble), which is the reliable cross-CLI delivery
|
|
13
|
+
* (no dependency on Claude's project-skill auto-discovery in headless mode).
|
|
14
|
+
*
|
|
15
|
+
* ADR-047 adds a `package` skill type — a native Claude Agent Skill (folder +
|
|
16
|
+
* scripts) installed into the container's Claude skills dir via `docker exec`
|
|
17
|
+
* (git clone or an install command), so Claude discovers it natively. Package
|
|
18
|
+
* skills are Claude-only and task-level (shared by the container's agents),
|
|
19
|
+
* NOT written into the flat per-agent SKILL.md.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { taskWorkspaceDir } from "./env";
|
|
25
|
+
import { dockerCli } from "./docker-exec";
|
|
26
|
+
import type { RosterAgent, RosterSkill } from "./agents/types";
|
|
27
|
+
|
|
28
|
+
/** Skills that go into the flat per-agent SKILL.md — link/document only. The
|
|
29
|
+
* ADR-047 `package` skills install into the container's skills dir instead. */
|
|
30
|
+
function flatSkills(agent: RosterAgent): RosterSkill[] {
|
|
31
|
+
return (agent.skills ?? []).filter((s) => s.type !== "package");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The container path of an agent's SKILL.md — what the preamble points at. */
|
|
35
|
+
export function containerSkillFile(agentId: string): string {
|
|
36
|
+
return `/workspace/.uai/agents/${agentId}/SKILL.md`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render an agent's skills into SKILL.md markdown ("everything in one file"). */
|
|
40
|
+
export function renderSkillMd(agent: RosterAgent): string {
|
|
41
|
+
const skills = flatSkills(agent);
|
|
42
|
+
const lines = [
|
|
43
|
+
`# ${agent.label} — skills`,
|
|
44
|
+
"",
|
|
45
|
+
"Reference material prepared for you (links and documents). Consult it when",
|
|
46
|
+
"a task touches what it covers.",
|
|
47
|
+
"",
|
|
48
|
+
];
|
|
49
|
+
for (const s of skills) {
|
|
50
|
+
lines.push(`## ${s.name}`, "");
|
|
51
|
+
if (s.type === "link") lines.push(`Link: ${s.value}`, "");
|
|
52
|
+
else lines.push(s.value.trim(), "");
|
|
53
|
+
}
|
|
54
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write an agent's SKILL.md into the task workspace (idempotent: overwrites,
|
|
59
|
+
* or removes the file when the agent has no skills). Returns the container path
|
|
60
|
+
* when written, else null. Best-effort — a write failure is swallowed so a
|
|
61
|
+
* skills problem never blocks the agent from running.
|
|
62
|
+
*/
|
|
63
|
+
export function writeAgentSkills(taskId: string, agent: RosterAgent): string | null {
|
|
64
|
+
const dir = resolve(taskWorkspaceDir(taskId), ".uai", "agents", agent.id);
|
|
65
|
+
const file = resolve(dir, "SKILL.md");
|
|
66
|
+
try {
|
|
67
|
+
if (flatSkills(agent).length === 0) {
|
|
68
|
+
rmSync(file, { force: true });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dir, { recursive: true });
|
|
72
|
+
writeFileSync(file, renderSkillMd(agent));
|
|
73
|
+
return containerSkillFile(agent.id);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// ADR-047 package skills — native Claude Agent Skills installed in-container.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const SKILL_EXEC_TIMEOUT_MS = 180_000; // clones / npx installs can be slow
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* In-container git-skill installer. Shallow-clones a repo, then works out where
|
|
87
|
+
* the actual skill(s) live and copies each into `~/.claude/skills/<name>/`:
|
|
88
|
+
*
|
|
89
|
+
* - `$2` (subpath) given → that folder is the skill.
|
|
90
|
+
* - else repo root has a SKILL.md → the whole repo is the skill.
|
|
91
|
+
* - else search the clone for SKILL.md files → install every skill folder it
|
|
92
|
+
* finds (a "collection" repo), each under its own folder name.
|
|
93
|
+
* - else exit 3 → the repo isn't a ready skill (needs a build; use cli mode).
|
|
94
|
+
*
|
|
95
|
+
* Params are positional argv ($1=url, $2=subpath, $3=name) — never interpolated
|
|
96
|
+
* into this text — so a repo URL / subpath can't shell-inject. `.git` is
|
|
97
|
+
* stripped and the temp clone removed; existing skill dirs are left untouched.
|
|
98
|
+
*/
|
|
99
|
+
const GIT_INSTALL_SCRIPT = `
|
|
100
|
+
set -eu
|
|
101
|
+
url="$1"; sub="$2"; name="$3"
|
|
102
|
+
skills="$HOME/.claude/skills"
|
|
103
|
+
mkdir -p "$skills"
|
|
104
|
+
tmp="$(mktemp -d)"
|
|
105
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
106
|
+
git clone --depth 1 --quiet "$url" "$tmp/repo"
|
|
107
|
+
repo="$tmp/repo"
|
|
108
|
+
install_one() {
|
|
109
|
+
src="$1"; dest="$skills/$2"
|
|
110
|
+
if [ -e "$dest" ]; then echo "skip $2"; return 0; fi
|
|
111
|
+
if [ ! -f "$src/SKILL.md" ]; then echo "no-skill-md: $src"; return 1; fi
|
|
112
|
+
rm -rf "$src/.git"
|
|
113
|
+
cp -R "$src" "$dest"
|
|
114
|
+
echo "installed $2"
|
|
115
|
+
}
|
|
116
|
+
if [ -n "$sub" ]; then
|
|
117
|
+
install_one "$repo/$sub" "$name"
|
|
118
|
+
elif [ -f "$repo/SKILL.md" ]; then
|
|
119
|
+
install_one "$repo" "$name"
|
|
120
|
+
else
|
|
121
|
+
n=0
|
|
122
|
+
while IFS= read -r -d '' md; do
|
|
123
|
+
d="$(dirname "$md")"
|
|
124
|
+
if install_one "$d" "$(basename "$d")"; then n=$((n+1)); fi
|
|
125
|
+
done < <(find "$repo" -maxdepth 4 -name SKILL.md -not -path '*/.git/*' -print0)
|
|
126
|
+
if [ "$n" -eq 0 ]; then echo "no-skills-found"; exit 3; fi
|
|
127
|
+
fi
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
/** A url-safe folder name for a skill (the dir under ~/.claude/skills/). */
|
|
131
|
+
export function skillSlug(name: string): string {
|
|
132
|
+
return (
|
|
133
|
+
name
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
136
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
137
|
+
.slice(0, 64) || "skill"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A short label for an installed package skill (for the preamble note). */
|
|
142
|
+
export function installedSkillPath(s: RosterSkill): string {
|
|
143
|
+
return s.source === "git"
|
|
144
|
+
? `~/.claude/skills/${skillSlug(s.name)}/`
|
|
145
|
+
: `${s.name} (install command)`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Package skills across the Claude agents in a roster, deduped by folder slug.
|
|
150
|
+
* Package skills are a Claude Code capability (ADR-047), so Codex agents are
|
|
151
|
+
* skipped; the install is task-level (one container, shared skills dir).
|
|
152
|
+
*/
|
|
153
|
+
export function collectPackageSkills(agents: RosterAgent[]): RosterSkill[] {
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
const out: RosterSkill[] = [];
|
|
156
|
+
for (const a of agents) {
|
|
157
|
+
if (a.kind !== "claude") continue;
|
|
158
|
+
for (const s of a.skills ?? []) {
|
|
159
|
+
if (s.type !== "package" || !s.value) continue;
|
|
160
|
+
const key = skillSlug(s.name);
|
|
161
|
+
if (seen.has(key)) continue;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
out.push(s);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Injectable docker-exec seam (mocked in tests). Runs `cmd` in `container` as
|
|
170
|
+
* the `node` user, optionally with a working dir. Values are argv, never a
|
|
171
|
+
* shell string — a URL/subpath can't shell-inject (cli mode is the exception:
|
|
172
|
+
* it runs the author's command via `bash -lc` by design). */
|
|
173
|
+
export type SkillExec = (
|
|
174
|
+
container: string,
|
|
175
|
+
cmd: string[],
|
|
176
|
+
cwd?: string,
|
|
177
|
+
) => Promise<{ status: number | null; stderr: string }>;
|
|
178
|
+
|
|
179
|
+
const dockerExec: SkillExec = async (container, cmd, cwd) => {
|
|
180
|
+
const full = ["exec", "-u", "node", ...(cwd ? ["-w", cwd] : []), container, ...cmd];
|
|
181
|
+
const res = await dockerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
|
|
182
|
+
return { status: res.status, stderr: res.stderr };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Install a roster's package skills into the task container (ADR-047). Runs at
|
|
187
|
+
* task-up (after the container is running), mirroring the git-identity/github
|
|
188
|
+
* `docker exec` setup: best-effort, idempotent, never blocks the agents.
|
|
189
|
+
*
|
|
190
|
+
* Returns the human-readable paths of skills that are now present (for the
|
|
191
|
+
* agents' preamble note). `exec` is injectable for tests.
|
|
192
|
+
*/
|
|
193
|
+
export async function installPackageSkills(
|
|
194
|
+
taskId: string,
|
|
195
|
+
agents: RosterAgent[],
|
|
196
|
+
exec: SkillExec = dockerExec,
|
|
197
|
+
): Promise<string[]> {
|
|
198
|
+
const pkgs = collectPackageSkills(agents);
|
|
199
|
+
if (pkgs.length === 0) return [];
|
|
200
|
+
const container = `task-${taskId}-app-1`;
|
|
201
|
+
const done: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const s of pkgs) {
|
|
204
|
+
const slug = skillSlug(s.name);
|
|
205
|
+
try {
|
|
206
|
+
if (s.source === "git") {
|
|
207
|
+
// Idempotent across ensureSessions re-runs: a marker records that the
|
|
208
|
+
// clone+install already ran, so reconnects don't re-clone.
|
|
209
|
+
const marker = `/home/node/.claude/skills/.uai-git-${slug}.done`;
|
|
210
|
+
if ((await exec(container, ["test", "-e", marker])).status === 0) {
|
|
211
|
+
done.push(installedSkillPath(s));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// subpath is de-fanged against traversal; url/subpath/name reach the
|
|
215
|
+
// installer as positional argv (never interpolated) — no shell inject.
|
|
216
|
+
const sub = (s.subpath ?? "").replace(/^[/.]+/, "").replace(/\.\.+/g, "");
|
|
217
|
+
const r = await exec(container, [
|
|
218
|
+
"bash",
|
|
219
|
+
"-lc",
|
|
220
|
+
GIT_INSTALL_SCRIPT,
|
|
221
|
+
"uai-skill", // $0
|
|
222
|
+
s.value, // $1 url
|
|
223
|
+
sub, // $2 subpath
|
|
224
|
+
slug, // $3 name
|
|
225
|
+
]);
|
|
226
|
+
if (r.status !== 0) {
|
|
227
|
+
const why =
|
|
228
|
+
r.status === 3
|
|
229
|
+
? "no SKILL.md found — the repo may need a build (try cli mode)"
|
|
230
|
+
: r.stderr.trim();
|
|
231
|
+
console.warn(`[skills] ${taskId}: git skill ${s.name} failed: ${why}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
|
|
235
|
+
await exec(container, ["touch", marker]);
|
|
236
|
+
console.log(`[skills] ${taskId}: installed git skill ${s.name}`);
|
|
237
|
+
done.push(installedSkillPath(s));
|
|
238
|
+
} else if (s.source === "cli") {
|
|
239
|
+
// Idempotent across ensureSessions re-runs (reconnects): a marker file
|
|
240
|
+
// records that the command already ran, since a CLI install isn't
|
|
241
|
+
// self-idempotent like `git clone` into an existing dir.
|
|
242
|
+
const marker = `/home/node/.claude/skills/.uai-cli-${slug}.done`;
|
|
243
|
+
if ((await exec(container, ["test", "-e", marker])).status === 0) {
|
|
244
|
+
done.push(installedSkillPath(s));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// The author's CLI drops files into .claude/skills; run it in the repo.
|
|
248
|
+
const c = await exec(container, ["bash", "-lc", s.value], "/workspace");
|
|
249
|
+
if (c.status !== 0) {
|
|
250
|
+
console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
|
|
254
|
+
await exec(container, ["touch", marker]);
|
|
255
|
+
console.log(`[skills] ${taskId}: ran install command for ${s.name}`);
|
|
256
|
+
done.push(installedSkillPath(s));
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.warn(`[skills] ${taskId}: package skill ${s.name} errored: ${String(e)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return done;
|
|
263
|
+
}
|