@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.
@@ -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
- // Container is ready: deliver each agent's first-turn prompt (ADR-022).
239
- // Only agents whose `initialPrompt` was non-empty have an entry.
240
- for (const agent of channel.roster) {
241
- const firstTurn = channel.firstTurns.get(agent.id);
242
- if (firstTurn === undefined) continue;
243
- const session = channel.sessions.get(agent.id);
244
- if (session) void session.send(firstTurn);
245
- }
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 others = roster.map((a) => `@${a.id} (${a.label})`).join(", ");
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
- `Agents in this channel: ${others}.`,
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**. When you need their",
745
- "input, a decision, or their attention a question, a blocker, an",
746
- "approval, or you've finished and are handing back to them end your",
747
- "message by @-mentioning **@you** (e.g. `@you which approach do you`",
748
- "`prefer?` or `@you done — PR is up for review`). That notifies them.",
784
+ "The human you're working with is **@you**. @-mentioning them sends a",
785
+ "NOTIFICATION, so use it sparinglyonly 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`). Never",
767
- "stop silently — if genuinely no agent needs to act, say so to the",
768
- "human.",
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
+ }