@flame0510/project-aether 1.1.13 → 1.1.14

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.
@@ -2,23 +2,42 @@ import { type NextRequest } from "next/server";
2
2
  /**
3
3
  * Gateway Status API
4
4
  *
5
- * Aggregates live model/provider state from Rev4a agent containers.
6
- * Only queries containers with AGENT_ID label — no hardcoded container names.
5
+ * Reports the per-agent model settings the Gateway page needs to render its
6
+ * agent cards and pre-fill the "edit default model" form. Only containers with
7
+ * an AGENT_ID label are considered — no hardcoded container names.
7
8
  *
8
9
  * GET /api/gateway
10
+ *
11
+ * This used to shell out to the OpenClaw CLI inside every container, twice:
12
+ * `openclaw models status --json` (~5.7s) and `openclaw models list --json`
13
+ * (~4.4s), serially, with execSync — so ~10s per agent with the event loop
14
+ * frozen throughout, and roughly 70s on a seven-agent host.
15
+ *
16
+ * Both were avoidable. `models list` produced a model catalogue and alias map
17
+ * that no line of the UI ever read: the page takes its model list from
18
+ * /api/gateway/provider, which reads local files. And `models status` returned,
19
+ * for the two fields actually used, exactly what sits in the agent's own
20
+ * openclaw.json — verified field by field against a live agent. So the CLI was
21
+ * being booted for five seconds to read a file that `cat` returns in 260ms.
22
+ *
23
+ * The response shape is unchanged so the page keeps working untouched; the
24
+ * fields that came only from the discarded CLI output are now reported empty
25
+ * rather than computed at great cost and thrown away.
9
26
  */
10
27
  import { NextResponse } from 'next/server';
11
- import { execSync } from 'child_process';
28
+ import { execFile } from 'child_process';
29
+ import { promisify } from 'util';
12
30
  import { requireAuthJWT } from '@/lib/rev4a-auth';
31
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
32
+
33
+ const execFileAsync = promisify(execFile);
13
34
 
14
35
  export const dynamic = 'force-dynamic';
15
36
 
16
- interface GatewayModel {
17
- key: string;
18
- provider: string;
19
- model: string;
20
- available: boolean;
21
- }
37
+ /** Agents queried at once. Each is a `docker exec … cat`, cheap but still a process. */
38
+ const AGENT_QUERY_CONCURRENCY = 8;
39
+
40
+ const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
22
41
 
23
42
  interface GatewayProvider {
24
43
  provider: string;
@@ -40,93 +59,78 @@ interface GatewayAgentStatus {
40
59
  error?: string;
41
60
  }
42
61
 
43
- interface ApiKeyStatus {
44
- provider: string;
45
- configured: boolean;
46
- }
47
-
48
- /** Run a command inside a Docker container */
49
- function dockerExec(container: string, cmd: string, timeout = 15000): string {
50
- try {
51
- return execSync(
52
- `docker exec ${container} sh -c ${JSON.stringify(cmd)}`,
53
- { timeout, maxBuffer: 1024 * 1024, encoding: 'utf-8' },
54
- );
55
- } catch {
56
- return '';
57
- }
62
+ interface AgentRef {
63
+ agentId: string;
64
+ containerName: string;
65
+ state: string;
58
66
  }
59
67
 
60
- /** Get Docker containers with AGENT_ID label — only Rev4a-managed agents */
61
- function getAgents(): { agentId: string; containerName: string; state: string }[] {
68
+ /**
69
+ * Containers carrying an AGENT_ID label, with the label read straight out of
70
+ * `docker ps` rather than a follow-up `docker inspect` per container.
71
+ */
72
+ async function getAgents(): Promise<AgentRef[]> {
62
73
  try {
63
- const raw = execSync(
64
- `docker ps --filter "label=AGENT_ID" --format '{{.Label "AGENT_ID"}}|{{.Names}}|{{.State}}'`,
65
- { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8' },
66
- ).trim();
67
- if (!raw) return [];
68
- return raw.split('\n').map((line) => {
69
- const [agentId, containerName, state] = line.split('|');
70
- return { agentId, containerName, state };
71
- });
74
+ const { stdout } = await execFileAsync('docker', [
75
+ 'ps', '--filter', 'label=AGENT_ID',
76
+ '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}',
77
+ ], { encoding: 'utf-8', timeout: 5000 });
78
+
79
+ return stdout.trim().split('\n')
80
+ .map((line) => {
81
+ const [agentId, containerName, state] = line.split('\t');
82
+ if (!containerName?.trim()) return null;
83
+ return {
84
+ agentId: agentId?.trim() || containerName.trim(),
85
+ containerName: containerName.trim(),
86
+ state: state?.trim() || 'unknown',
87
+ };
88
+ })
89
+ .filter(Boolean) as AgentRef[];
72
90
  } catch {
73
91
  return [];
74
92
  }
75
93
  }
76
94
 
77
- /** Query an agent container for model status */
78
- function getAgentStatus(agent: { agentId: string; containerName: string; state: string }): GatewayAgentStatus | null {
79
- const raw = dockerExec(agent.containerName, 'openclaw models status --json', 8000);
80
- if (!raw) return null;
81
- try {
82
- const data = JSON.parse(raw);
83
- const authProviders: Record<string, unknown>[] = data.auth?.providers ?? [];
84
- const providers: GatewayProvider[] = authProviders.map((p: Record<string, unknown>) => ({
85
- provider: String(p.provider ?? ''),
86
- kind: String((p.effective as Record<string, unknown>)?.kind ?? ''),
87
- detail: String((p.effective as Record<string, unknown>)?.detail ?? ''),
88
- profiles: Number((p.profiles as Record<string, unknown>)?.count ?? 0),
89
- labels: (p.profiles as Record<string, unknown>)?.labels as string[] ?? [],
90
- }));
91
-
92
- const aliasesRaw = data.aliases ?? {};
93
- const aliases: Record<string, string> = {};
94
- for (const [k, v] of Object.entries(aliasesRaw)) {
95
- aliases[k] = String(v);
96
- }
95
+ /**
96
+ * Read an agent's model settings out of its openclaw.json.
97
+ *
98
+ * `agents.defaults.model` is the same value `openclaw models status` reports as
99
+ * `defaultModel`/`fallbacks`. The CLI additionally resolves an `imageModel`
100
+ * default when the config sets none, but the base image defines no such default
101
+ * (verified: `imageModel: null`), and agent creation always writes an explicit
102
+ * model — so there is no case where the file and the CLI would disagree.
103
+ */
104
+ async function readAgentStatus(agent: AgentRef): Promise<GatewayAgentStatus> {
105
+ const raw = await dockerExecNoFail(
106
+ agent.containerName,
107
+ ['cat', AGENT_CONFIG_PATH],
108
+ { timeoutMs: 8000 },
109
+ );
110
+
111
+ const base: GatewayAgentStatus = {
112
+ agentId: agent.agentId,
113
+ containerName: agent.containerName,
114
+ state: agent.state,
115
+ defaultModel: '',
116
+ fallbacks: [],
117
+ allowed: [],
118
+ aliases: {},
119
+ providers: [],
120
+ };
121
+
122
+ if (!raw) return { ...base, error: 'config unreadable' };
97
123
 
124
+ try {
125
+ const config = JSON.parse(raw) as Record<string, any>;
126
+ const model = config?.agents?.defaults?.model ?? {};
98
127
  return {
99
- agentId: agent.agentId,
100
- containerName: agent.containerName,
101
- state: agent.state,
102
- defaultModel: String(data.defaultModel ?? ''),
103
- fallbacks: (data.fallbacks ?? []).map(String),
104
- allowed: (data.allowed ?? []).map(String),
105
- aliases,
106
- providers,
128
+ ...base,
129
+ defaultModel: typeof model.primary === 'string' ? model.primary : '',
130
+ fallbacks: Array.isArray(model.fallbacks) ? model.fallbacks.map(String) : [],
107
131
  };
108
132
  } catch {
109
- return null;
110
- }
111
- }
112
-
113
- /** Get available models from an agent */
114
- function getAgentModels(container: string): GatewayModel[] {
115
- const raw = dockerExec(container, 'openclaw models list --json', 8000);
116
- if (!raw) return [];
117
- try {
118
- const parsed = JSON.parse(raw);
119
- const models: Record<string, unknown>[] = parsed.models ?? parsed ?? [];
120
- return models
121
- .filter((m: Record<string, unknown>) => m.available !== false && !m.missing)
122
- .map((m: Record<string, unknown>) => ({
123
- key: String(m.key ?? ''),
124
- provider: String(m.key ?? '').split('/')[0] || 'unknown',
125
- model: String(m.name ?? String(m.key ?? '').split('/').slice(1).join('/')),
126
- available: m.available !== false,
127
- }));
128
- } catch {
129
- return [];
133
+ return { ...base, error: 'config unparseable' };
130
134
  }
131
135
  }
132
136
 
@@ -136,64 +140,24 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
136
140
  try {
137
141
  const timestamp = Date.now();
138
142
 
139
- // Only Rev4a-managed agents (AGENT_ID label)
140
- const agentsRaw = getAgents();
141
- const agents = agentsRaw
142
- .map((a) => getAgentStatus(a))
143
- .filter(Boolean) as GatewayAgentStatus[];
144
-
145
- // Aggregate models from all agents (deduplicated by key)
146
- const modelMap = new Map<string, GatewayModel>();
147
- for (const agent of agents) {
148
- for (const m of getAgentModels(agent.containerName)) {
149
- if (!modelMap.has(m.key)) modelMap.set(m.key, m);
150
- }
151
- }
152
- const allModels = Array.from(modelMap.values());
153
- const availableModels = allModels.filter((m) => m.available);
154
-
155
- // Aggregate aliases from all agents
156
- const combinedAliases: Record<string, string[]> = {};
157
- for (const agent of agents) {
158
- for (const [alias, model] of Object.entries(agent.aliases)) {
159
- if (!combinedAliases[alias]) combinedAliases[alias] = [];
160
- if (!combinedAliases[alias].includes(model)) {
161
- combinedAliases[alias].push(model);
162
- }
163
- }
164
- }
143
+ const refs = await getAgents();
144
+ const agents = await mapWithConcurrency(refs, AGENT_QUERY_CONCURRENCY, readAgentStatus);
165
145
 
166
146
  // First agent is the "primary" for core model settings
167
147
  const primaryAgent = agents[0] ?? null;
168
148
 
169
- // API keys from the first agent's providers
170
- const apiKeys: ApiKeyStatus[] = [];
171
- if (primaryAgent) {
172
- for (const p of primaryAgent.providers) {
173
- // We can't query apiKey presence from openclaw models status (it's redacted).
174
- // Mark as configured if the provider appears in the auth list.
175
- apiKeys.push({
176
- provider: p.provider,
177
- configured: p.profiles > 0,
178
- });
179
- }
180
- }
181
-
182
- // Models by provider
183
- const byProvider = allModels.reduce<Record<string, number>>((acc, m) => {
184
- acc[m.provider] = (acc[m.provider] ?? 0) + 1;
185
- return acc;
186
- }, {});
187
-
188
149
  return NextResponse.json({
189
150
  timestamp,
190
151
  gateway: 'Rev4a Model Gateway',
191
152
  status: 'online',
153
+ // The model catalogue lives in /api/gateway/provider, which reads the
154
+ // bundled config plus user overrides. Recomputing it per agent here cost
155
+ // 4.4s each and was never read.
192
156
  models: {
193
- total: allModels.length,
194
- available: availableModels.length,
195
- byProvider: Object.entries(byProvider).map(([provider, count]) => ({ provider, count })),
196
- list: allModels,
157
+ total: 0,
158
+ available: 0,
159
+ byProvider: [],
160
+ list: [],
197
161
  },
198
162
  agents: {
199
163
  total: agents.length,
@@ -207,16 +171,18 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
207
171
  providers: a.providers,
208
172
  })),
209
173
  },
210
- aliases: combinedAliases,
174
+ aliases: {},
211
175
  coreModel: primaryAgent
212
176
  ? {
213
177
  defaultModel: primaryAgent.defaultModel,
214
178
  fallbacks: primaryAgent.fallbacks,
215
179
  }
216
180
  : null,
181
+ // Provider credential state comes from /api/gateway/provider (`configured`
182
+ // per provider), which reads provider-keys.json directly.
217
183
  apiKeys: {
218
- configured: apiKeys.filter((k) => k.configured).map((k) => k.provider),
219
- all: apiKeys,
184
+ configured: [],
185
+ all: [],
220
186
  },
221
187
  });
222
188
  } catch (e: unknown) {
@@ -79,16 +79,6 @@ function writeContainerJson(container: string, remotePath: string, data: Record<
79
79
  );
80
80
  }
81
81
 
82
- function restartContainerGateway(container: string): string | null {
83
- try {
84
- execSync(`docker exec ${container} openclaw gateway restart`, { timeout: 15000, maxBuffer: 64 * 1024 });
85
- return null;
86
- } catch (e: unknown) {
87
- const err = e as { stdout?: string; stderr?: string; message?: string };
88
- return err.stderr?.toString().trim() || err.message || 'unknown error';
89
- }
90
- }
91
-
92
82
  /* ------------------------------------------------------------------ */
93
83
  /* Sync functions */
94
84
  /* ------------------------------------------------------------------ */
@@ -181,9 +171,16 @@ function syncContainer(
181
171
 
182
172
  writeContainerJson(container, remotePath, config);
183
173
 
184
- const gwError = restartContainerGateway(container);
185
- if (gwError) {
186
- return `${container}: file updated but gateway restart failed: ${gwError}`;
187
- }
188
- return `${container}: synced + restarted OK`;
174
+ // No gateway restart: this only writes models.providers.rev4a, and the Gateway
175
+ // watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
176
+ // so out loud — "Change will apply without restarting the gateway" — and its
177
+ // docs put `models` in the no-restart column, with `gateway.reload` defaulting
178
+ // to `hybrid`, which additionally restarts by itself for the changes that do
179
+ // need it. Verified against a live agent: every agent here leaves
180
+ // `gateway.reload` unset, so they all run that default.
181
+ //
182
+ // The restart used to cost ~7.5s per container, serially — roughly 52s of
183
+ // fully blocked event loop on a seven-agent host, every time a provider key or
184
+ // model toggle was saved, to do something the Gateway had already done.
185
+ return `${container}: synced`;
189
186
  }
@@ -5,8 +5,6 @@
5
5
  * Used by both /api/provider/v1/chat/completions and /api/assistant
6
6
  * to avoid HTTP loopback (calling localhost from inside Next.js).
7
7
  */
8
- import { detectPublicHost } from '@/lib/docker-utils';
9
-
10
8
  export const dynamic = 'force-dynamic';
11
9
 
12
10
  export interface ProviderEnvConfig {
@@ -127,7 +125,18 @@ export async function callUpstream(
127
125
  }
128
126
 
129
127
  if (provider === 'openrouter') {
130
- headers['HTTP-Referer'] = `http://${detectPublicHost()}:3740`;
128
+ // Attribution only: OpenRouter uses this pair to identify the app on its
129
+ // public leaderboard. It never resolves or contacts the URL, and it has no
130
+ // effect on routing, rate limits, pricing, or whether the call succeeds.
131
+ //
132
+ // This used to be `http://${detectPublicHost()}:3740`, which shelled out to
133
+ // curl synchronously — blocking the whole event loop for ~270ms before every
134
+ // OpenRouter call, and 55 of the 69 models enabled in the catalogue are
135
+ // OpenRouter ones. It also fragmented the attribution it was supposed to
136
+ // provide: every install reported a different host, so each one showed up as
137
+ // a separate app. A per-install address was never the right value here —
138
+ // X-Title, the other half of the same label, was always a constant.
139
+ headers['HTTP-Referer'] = 'https://rev4a.app';
131
140
  headers['X-Title'] = 'Rev4a Gateway';
132
141
  }
133
142
 
@@ -1,11 +1,19 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { execSync, execFileSync } from 'child_process';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
5
6
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
7
  import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
8
+ import { dockerExecShellNoFail, mapWithConcurrency } from '@/lib/docker-exec';
9
+
10
+ const execFileAsync = promisify(execFile);
7
11
 
8
12
  const CONTAINER_BUNDLED_SKILLS_DIR = '/usr/local/lib/node_modules/openclaw/skills';
13
+ const WORKSPACE_SKILLS_DIR = '/root/.openclaw/workspace/skills';
14
+
15
+ /** Agents read at once — each is a single bulk `docker exec`. */
16
+ const AGENT_QUERY_CONCURRENCY = 6;
9
17
 
10
18
  function readSkillMeta(skillDir, name, type, agentId) {
11
19
  const skillMdPath = path.join(skillDir, name, 'SKILL.md');
@@ -41,22 +49,19 @@ function readSkillMeta(skillDir, name, type, agentId) {
41
49
  * Discover agent containers with AGENT_ID label.
42
50
  * Returns [{ containerName, agentId }]
43
51
  */
44
- function discoverAgentContainers() {
52
+ async function discoverAgentContainers() {
45
53
  try {
46
- // Get container names with AGENT_ID label
47
- const namesRaw = execFileSync('docker', [
48
- 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}'
49
- ], { encoding: 'utf8', timeout: 5000 }).trim();
50
- if (!namesRaw) return [];
51
-
52
- return namesRaw.split('\n').map(name => {
53
- const containerName = name.trim();
54
- if (!containerName) return null;
55
- // Get agent ID from label using docker inspect
56
- const agentId = execFileSync('docker', [
57
- 'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
58
- ], { encoding: 'utf8', timeout: 5000 }).trim();
59
- return { containerName, agentId: agentId || containerName };
54
+ // `docker ps` prints labels itself, so the AGENT_ID comes back with the name.
55
+ // This used to follow up with a `docker inspect` per container to read that
56
+ // same label — N+1 process spawns for data the first call already had.
57
+ const { stdout } = await execFileAsync('docker', [
58
+ 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}'
59
+ ], { encoding: 'utf8', timeout: 5000 });
60
+
61
+ return stdout.trim().split('\n').map(line => {
62
+ const [agentId, containerName] = line.split('\t');
63
+ if (!containerName?.trim()) return null;
64
+ return { containerName: containerName.trim(), agentId: agentId?.trim() || containerName.trim() };
60
65
  }).filter(Boolean);
61
66
  } catch {
62
67
  return [];
@@ -64,42 +69,44 @@ function discoverAgentContainers() {
64
69
  }
65
70
 
66
71
  /**
67
- * Read skill directories from a container agent's workspace via docker exec.
68
- * Returns an array of skill names found.
72
+ * Read every SKILL.md under `dir` in one shot.
73
+ *
74
+ * The previous approach ran `docker exec … cat` once per skill. With 52 bundled
75
+ * skills in the base image that was 52 sequential process spawns at ~260ms each
76
+ * — roughly 13s of blocked event loop to read 52 text files sitting in the same
77
+ * container.
78
+ *
79
+ * The shell loop emits `path\0content\0` pairs. NUL is the delimiter precisely
80
+ * because it cannot occur inside the text files being read, so no SKILL.md can
81
+ * forge a record boundary — a risk any printable separator would carry.
82
+ *
83
+ * Returns a Map of skill name → SKILL.md contents.
69
84
  */
70
- function readContainerSkillDirs(containerName) {
71
- const workspaceSkillsPath = '/root/.openclaw/workspace/skills';
72
- try {
73
- // Check if the skills directory exists
74
- execFileSync('docker', ['exec', containerName, 'test', '-d', workspaceSkillsPath], {
75
- encoding: 'utf8', timeout: 5000
76
- });
77
-
78
- // List subdirectories (each is a potential skill)
79
- const raw = execFileSync('docker', ['exec', containerName, 'ls', '-1', workspaceSkillsPath], {
80
- encoding: 'utf8', timeout: 5000
81
- }).trim();
82
-
83
- if (!raw) return [];
84
-
85
- return raw.split('\n').map(line => line.trim()).filter(Boolean);
86
- } catch {
87
- return [];
88
- }
89
- }
90
-
91
- /**
92
- * Read SKILL.md content from inside a container.
93
- */
94
- function readContainerSkillMd(containerName, skillName) {
95
- const filePath = `/root/.openclaw/workspace/skills/${skillName}/SKILL.md`;
96
- try {
97
- return execFileSync('docker', ['exec', containerName, 'cat', filePath], {
98
- encoding: 'utf8', timeout: 5000
99
- });
100
- } catch {
101
- return null;
85
+ async function readSkillsBulk(containerName, dir) {
86
+ const script =
87
+ `for d in ${dir}/*/; do ` +
88
+ `[ -f "$d/SKILL.md" ] || continue; ` +
89
+ `n=$(basename "$d"); ` +
90
+ `printf '%s\\0' "$n"; ` +
91
+ `cat "$d/SKILL.md"; ` +
92
+ `printf '\\0'; ` +
93
+ `done`;
94
+
95
+ const raw = await dockerExecShellNoFail(containerName, script, {
96
+ timeoutMs: 15000,
97
+ maxBuffer: 8 * 1024 * 1024,
98
+ });
99
+
100
+ const out = new Map();
101
+ if (!raw) return out;
102
+
103
+ const parts = raw.split('\0');
104
+ // Pairs of name, content — a trailing empty element from the final NUL is fine.
105
+ for (let i = 0; i + 1 < parts.length; i += 2) {
106
+ const name = parts[i].trim();
107
+ if (name) out.set(name, parts[i + 1]);
102
108
  }
109
+ return out;
103
110
  }
104
111
 
105
112
  /**
@@ -136,66 +143,51 @@ export async function GET(request) {
136
143
  }
137
144
  } catch {}
138
145
 
139
- // 2. Agent container skills (workspace-local)
140
- const agents = discoverAgentContainers();
141
- for (const agent of agents) {
142
- const dirs = readContainerSkillDirs(agent.containerName);
143
- for (const dir of dirs) {
144
- const content = readContainerSkillMd(agent.containerName, dir);
145
- if (content) {
146
- const { description, version } = parseSkillContent(content);
147
- skills.push({
148
- name: dir,
149
- type: 'workspace',
150
- agentId: agent.agentId,
151
- containerName: agent.containerName,
152
- path: `/root/.openclaw/workspace/skills/${dir}`,
153
- skillMdPath: `/root/.openclaw/workspace/skills/${dir}/SKILL.md`,
154
- hasSkillMd: true,
155
- description,
156
- version,
157
- });
158
- }
146
+ // 2. Agent container skills (workspace-local) — one bulk read per agent,
147
+ // agents queried concurrently.
148
+ const agents = await discoverAgentContainers();
149
+ const perAgent = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, (agent) =>
150
+ readSkillsBulk(agent.containerName, WORKSPACE_SKILLS_DIR),
151
+ );
152
+
153
+ for (const [i, found] of perAgent.entries()) {
154
+ const agent = agents[i];
155
+ for (const [name, content] of found) {
156
+ const { description, version } = parseSkillContent(content);
157
+ skills.push({
158
+ name,
159
+ type: 'workspace',
160
+ agentId: agent.agentId,
161
+ containerName: agent.containerName,
162
+ path: `${WORKSPACE_SKILLS_DIR}/${name}`,
163
+ skillMdPath: `${WORKSPACE_SKILLS_DIR}/${name}/SKILL.md`,
164
+ hasSkillMd: true,
165
+ description,
166
+ version,
167
+ });
159
168
  }
160
169
  }
161
170
 
162
- // 3. Bundled skills (read-only) — discover from any agent container
171
+ // 3. Bundled skills (read-only) — identical in every agent, so the first
172
+ // container that answers is enough. One bulk read instead of 52 `cat`s.
163
173
  try {
164
- let bundledDirNames = [];
165
- let bundledSourceContainer = null;
166
-
167
- if (agents.length > 0) {
168
- for (const agent of agents) {
169
- try {
170
- const raw = execFileSync('docker', [
171
- 'exec', agent.containerName, 'ls', '-1', CONTAINER_BUNDLED_SKILLS_DIR
172
- ], { encoding: 'utf8', timeout: 5000 }).trim();
173
- if (raw) {
174
- bundledDirNames = raw.split('\n').map(l => l.trim()).filter(Boolean);
175
- bundledSourceContainer = agent.containerName;
176
- break;
177
- }
178
- } catch { /* try next container */ }
179
- }
180
- }
174
+ for (const agent of agents) {
175
+ const found = await readSkillsBulk(agent.containerName, CONTAINER_BUNDLED_SKILLS_DIR);
176
+ if (found.size === 0) continue; // container not ready — try the next one
181
177
 
182
- for (const name of bundledDirNames) {
183
- try {
184
- const skillMdPath = `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`;
185
- const content = execFileSync('docker', [
186
- 'exec', bundledSourceContainer, 'cat', skillMdPath
187
- ], { encoding: 'utf8', timeout: 5000 });
178
+ for (const [name, content] of found) {
188
179
  const { description, version } = parseSkillContent(content);
189
180
  skills.push({
190
181
  name,
191
182
  type: 'bundled',
192
183
  path: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}`,
193
- skillMdPath,
184
+ skillMdPath: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`,
194
185
  hasSkillMd: true,
195
186
  description,
196
187
  version,
197
188
  });
198
- } catch { /* skip this skill */ }
189
+ }
190
+ break;
199
191
  }
200
192
  } catch {}
201
193