eve-hub 0.16.18 → 0.16.20

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/bash-run.mjs CHANGED
@@ -10,7 +10,7 @@ import { lstatSync, readlinkSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { isAbsolute, join, resolve } from "node:path";
12
12
  import { hubSelfStopVerb, selfStopMessage, stripHeredocs } from "./self-restart.mjs";
13
- import { readWorkspaceFile } from "./workspace.mjs";
13
+ import { coordinatorOf, readWorkspaceFile } from "./workspace.mjs";
14
14
 
15
15
  export const DEFAULT_TIMEOUT_S = 180;
16
16
  export const MAX_TIMEOUT_S = 1800;
@@ -231,12 +231,15 @@ export function linkedInstallTargets(command, cwd) {
231
231
 
232
232
  // `sandbox`: an eve SandboxSession (spawn -> { stdout, stderr, wait, kill }).
233
233
  // `hub stop` / `hub restart` from a bot's own shell (the daemon sets HUB_AGENT
234
- // for every bot) takes down the bot running it (self-restart.mjs); main uses
234
+ // for every bot) takes down the bot running it (self-restart.mjs); the coordinator uses
235
235
  // its restart_hub tool instead.
236
236
  export function selfStopRefusal(command, env = process.env) {
237
237
  if (!env.HUB_AGENT) return null;
238
238
  const verb = hubSelfStopVerb(maskQuoted(stripHeredocs(command)));
239
- return verb ? `[${selfStopMessage(verb, env.HUB_AGENT)}]` : null;
239
+ if (!verb) return null;
240
+ const root = env.HUB_WORKSPACE?.trim() || env.HUB_ROOT?.trim();
241
+ if (!root) throw new Error("HUB_AGENT is set without HUB_WORKSPACE or HUB_ROOT, so the coordinator can't be read");
242
+ return `[${selfStopMessage(verb, env.HUB_AGENT, coordinatorOf(readWorkspaceFile(resolve(root)).agents))}]`;
240
243
  }
241
244
 
242
245
  /**
@@ -10,7 +10,7 @@
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { section, templatesDir } from "./main-instructions.mjs";
13
- import { agentRunsOn, parseWorkspace } from "./workspace.mjs";
13
+ import { agentRunsOn, coordinatorOf, parseWorkspace } from "./workspace.mjs";
14
14
 
15
15
  const page = (name, options) => readFileSync(join(templatesDir(options), `execution-context-${name}.md`), "utf8").trimEnd();
16
16
 
@@ -40,7 +40,7 @@ export function deploymentFacts(env = process.env) {
40
40
 
41
41
  const list = (items) => items.map((item) => `\`${item}\``).join(", ");
42
42
 
43
- export function hostedContext({ bot, kit, removed, deployment: d, git = false }, options) {
43
+ export function hostedContext({ bot, coordinator, kit, removed, deployment: d, git = false }, options) {
44
44
  const dashboard = d.team && d.project ? `https://vercel.com/${d.team}/${d.project}` : undefined;
45
45
  const lines = [
46
46
  `You are \`${bot}\`, an eve agent deployed on Vercel, not on a laptop: \`hub.workspace.json\` defines you as hosted.`,
@@ -65,7 +65,7 @@ export function hostedContext({ bot, kit, removed, deployment: d, git = false },
65
65
  "- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
66
66
  `- **Kit tools this build removed:** ${removed.length ? list(removed) : "none"}. They need the hub's machine, or change the roster, which a deployment does by commit.`,
67
67
  `- **Logs:** ${dashboard ? `[runtime logs](${dashboard}/logs)${d.deployment ? ` and [this deployment](${dashboard}/${d.deployment.replace(/^dpl_/, "")})` : ""} in the Vercel dashboard` : "the Vercel project's runtime logs in the dashboard"}.`,
68
- ...(bot === "main" ? [page("hosted-main", options)] : []),
68
+ ...(coordinator ? [page("hosted-main", options)] : []),
69
69
  ];
70
70
  return `${section("execution_context", lines.join("\n"))}\n`;
71
71
  }
@@ -78,6 +78,6 @@ export function executionContext(bot, workspace, options) {
78
78
  if (runsOn !== "local") {
79
79
  throw new Error(`${bot} is defined as hosted in hub.workspace.json, and a hosted bot's execution context is written by its Vercel build (eve-hub/vercel). A bot on this machine needs "runsOn": "local", on the agent or at the top of hub.workspace.json.`);
80
80
  }
81
- const lines = [`You are \`${bot}\`: \`hub.workspace.json\` defines you as local.`, "", page("local", options), ...(bot === "main" ? [page("local-main", options)] : [])];
81
+ const lines = [`You are \`${bot}\`: \`hub.workspace.json\` defines you as local.`, "", page("local", options), ...(coordinatorOf(parsed.agents) === bot ? [page("local-main", options)] : [])];
82
82
  return `${section("execution_context", lines.join("\n"))}\n`;
83
83
  }
package/kit-change.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // `git__gh` runs in (migrateSandbox). Customized files are left alone and reported.
5
5
  import { isKitFile, syncKit } from "./sync-kit.mjs";
6
6
  import { migrateSandbox } from "./sandbox-migrate.mjs";
7
- import { agentRunsOn, parseWorkspace, WorkspaceError, WORKSPACE_FILE } from "./workspace.mjs";
7
+ import { agentRunsOn, coordinatorOf, parseWorkspace, WorkspaceError, WORKSPACE_FILE } from "./workspace.mjs";
8
8
  import KIT_MODULES from "./kit-modules.json" with { type: "json" };
9
9
 
10
10
  const AGENT_FILE = /^agents\/([^/]+)\/agent\/(.+)$/;
@@ -37,7 +37,7 @@ export async function kitChange({ paths, read }) {
37
37
  }
38
38
  const tiersOf = (name) => Object.keys(definition.agents.find((a) => a.name === name)?.subagents ?? {});
39
39
  const bots = [...files].map(([name, f]) => ({ name, runsOn: agentRunsOn(definition, name), files: f, subagents: tiersOf(name) }));
40
- const change = syncKit({ bots, modules: KIT_MODULES });
40
+ const change = syncKit({ bots, modules: KIT_MODULES, coordinator: coordinatorOf(definition.agents) });
41
41
  const path = (bot, file) => `agents/${bot}/agent/${file}`;
42
42
  const world = agentFiles.filter((f) => f.text.includes("eve-hub/world")).map((f) => ({ path: f.path, text: f.text.replace(HUB_WORLD_LINE, "") }));
43
43
  const moved = sandboxes.map((f) => ({ path: f.path, text: f.text, ...migrateSandbox(f.text) }));
@@ -12,7 +12,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { ROLE_DIR, ROLE_FILE } from "./role-files.mjs";
15
- import { WorkspaceError, findWorkspaceRoot } from "./workspace.mjs";
15
+ import { WorkspaceError, coordinatorOf, findWorkspaceRoot, readWorkspaceFile } from "./workspace.mjs";
16
16
 
17
17
  const HEAD = "main-instructions.md";
18
18
  const ENGINEERING = "engineering.md";
@@ -58,6 +58,7 @@ const assemble = ({ identity, role }, additions, shared, options) =>
58
58
  // A bot's own additions to its role: agents/<bot>/agent/role/*.md, by file name, inside <role>.
59
59
  // They can't sit in instructions/*.md: eve loads that folder itself, after execution-context.ts,
60
60
  // outside every tag. A workspace with no folder (or no workspace at all) has none.
61
+ // `bot` null: the workspace's coordinator, whatever its name.
61
62
  function additionsOf(bot, options) {
62
63
  let root;
63
64
  try {
@@ -66,6 +67,8 @@ function additionsOf(bot, options) {
66
67
  if (error instanceof WorkspaceError) return [];
67
68
  throw error;
68
69
  }
70
+ bot ??= coordinatorOf(readWorkspaceFile(root).agents);
71
+ if (!bot) return [];
69
72
  const dir = join(root, "agents", bot, "agent", ROLE_DIR);
70
73
  if (!existsSync(dir)) return [];
71
74
  return readdirSync(dir).filter((f) => f.endsWith(".md")).sort().map((f) => readFileSync(join(dir, f), "utf8").trim());
@@ -73,7 +76,7 @@ function additionsOf(bot, options) {
73
76
 
74
77
  // Main never builds, so it goes without <engineering>.
75
78
  const MAIN_SHARED = SHARED.filter(([tag]) => tag !== ENGINEERING.slice(0, -3));
76
- export const mainInstructions = (options) => assemble(splitRole(template(HEAD, options), HEAD), additionsOf("main", options), MAIN_SHARED, options);
79
+ export const mainInstructions = (options) => assemble(splitRole(template(HEAD, options), HEAD), additionsOf(null, options), MAIN_SHARED, options);
77
80
 
78
81
  // Every other bot: agents/<bot>/agent/role.md in the workspace.
79
82
  export { ROLE_DIR, ROLE_FILE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.18",
3
+ "version": "0.16.20",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
package/prepare.mjs CHANGED
@@ -25,7 +25,7 @@ import { applyKit, readBotFiles, syncKit } from "./sync-kit.mjs";
25
25
  export { DEPLOYED_KIT_FILES };
26
26
  import { deploymentFacts, hostedContext } from "./execution-context.mjs";
27
27
  import { activeEntries, botDomains, domainFolders, entryText, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
28
- import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
28
+ import { agentRunsOn, coordinatorOf, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
29
29
  import { architectureText } from "./team.mjs";
30
30
  import { sandboxReady } from "./sandbox-migrate.mjs";
31
31
  import { GITHUB_CONNECTOR } from "./github-connector.mjs";
@@ -178,10 +178,10 @@ export default dashSchedule({ decisions: () => decisions });
178
178
  // Replaces the kit's execution-context stub (the local page) with the hosted
179
179
  // page, for a bot the definition says is hosted. Its removed-tools list is read
180
180
  // from the bot's folder as pruned.
181
- function writeExecutionContext(bot, { bot: name, deployment }) {
182
- const kitTools = Object.keys(name === "main" ? MAIN_KIT_FILES : KIT_FILES).filter((file) => file.startsWith("tools/") && file !== "tools/bash.ts").map((file) => basename(file, ".ts"));
181
+ function writeExecutionContext(bot, { bot: name, coordinator, deployment }) {
182
+ const kitTools = Object.keys(coordinator ? MAIN_KIT_FILES : KIT_FILES).filter((file) => file.startsWith("tools/") && file !== "tools/bash.ts").map((file) => basename(file, ".ts"));
183
183
  const removed = kitTools.filter((tool) => !existsSync(join(bot, "tools", `${tool}.ts`)));
184
- const section = hostedContext({ bot: name, kit: KIT_VERSION, removed, deployment, git: existsSync(join(bot, GIT_EXTENSION_FILE)) });
184
+ const section = hostedContext({ bot: name, coordinator, kit: KIT_VERSION, removed, deployment, git: existsSync(join(bot, GIT_EXTENSION_FILE)) });
185
185
  mkdirSync(join(bot, "instructions"), { recursive: true });
186
186
  writeFileSync(join(bot, "instructions", "execution-context.ts"), `import { defineInstructions } from "eve/instructions";\n\nexport default defineInstructions({ content: ${JSON.stringify(section)} });\n`);
187
187
  }
@@ -193,12 +193,13 @@ export function prepareDeployment(dir) {
193
193
  if (existsSync(join(dir, MANIFEST_FILE))) return;
194
194
  validateMemory(dir);
195
195
  const definition = readWorkspaceFile(dir);
196
+ const coordinator = coordinatorOf(definition.agents);
196
197
  const resolved = { ...definition, agents: definition.agents.map((agent) => ({ ...agent, runsOn: agentRunsOn(definition, agent.name) })) };
197
198
  const dirOf = (name) => join(dir, "agents", name, "agent");
198
199
  const local = new Set(resolved.agents.filter((a) => a.runsOn === "local").map((a) => a.name));
199
200
  const folders = readdirSync(join(dir, "agents")).filter((name) => !local.has(name) && existsSync(join(dirOf(name), "agent.ts")));
200
201
  const tiersOf = (name) => Object.keys(definition.agents.find((a) => a.name === name)?.subagents ?? {});
201
- const change = syncKit({ bots: folders.map((name) => ({ name, runsOn: "hosted", files: readBotFiles(dirOf(name)), subagents: tiersOf(name) })), modules: KIT_MODULES });
202
+ const change = syncKit({ bots: folders.map((name) => ({ name, runsOn: "hosted", files: readBotFiles(dirOf(name)), subagents: tiersOf(name) })), modules: KIT_MODULES, coordinator });
202
203
  if (change.report.length) throw new Error(`these files can't ship to a hosted deployment; update or delete them:\n${change.report.map((r) => ` agents/${r.bot}/agent/${r.file} ${r.reason}`).join("\n")}`);
203
204
  const deployment = deploymentFacts();
204
205
  // The git extension's `git__gh` runs eve-code's gh; a sandbox an earlier template wrote has none. The hub's kit update moves it (sandbox-migrate.mjs).
@@ -210,8 +211,8 @@ export function prepareDeployment(dir) {
210
211
  removeWorkspaceAgent(dir, name);
211
212
  rmSync(join(dir, "agents", name), { recursive: true, force: true });
212
213
  }
213
- mkdirSync(join(dir, "agents", "main", "agent", "channels"), { recursive: true });
214
- writeFileSync(join(dir, "agents", "main", "agent", "channels", "manifest.ts"), MANIFEST_CHANNEL);
214
+ mkdirSync(join(dirOf(coordinator), "channels"), { recursive: true });
215
+ writeFileSync(join(dirOf(coordinator), "channels", "manifest.ts"), MANIFEST_CHANNEL);
215
216
  applyKit(change, dirOf);
216
217
  // Bots only: a declared subagent's agent.ts (agent/subagents/<tier>/) is not a bot and mounts none of these.
217
218
  for (const name of readdirSync(join(dir, "agents"))) {
@@ -230,7 +231,7 @@ export function prepareDeployment(dir) {
230
231
  }
231
232
  for (const name of readdirSync(join(dir, "agents"))) {
232
233
  const bot = join(dir, "agents", name, "agent");
233
- writeExecutionContext(bot, { bot: name, deployment });
234
+ writeExecutionContext(bot, { bot: name, coordinator: name === coordinator, deployment });
234
235
  // The kit's extension mounts only on the hub's machine and is excluded from the kit tarball.
235
236
  // Other extensions, such as eve-code, stay.
236
237
  for (const item of readdirSync(bot, { recursive: true })) {
@@ -244,7 +245,7 @@ export function prepareDeployment(dir) {
244
245
  const botDir = join(dir, "agents", name);
245
246
  const { entries } = readMemory(dir, { folders: domainFolders(botDomains(dir, botDir)) });
246
247
  writeFileSync(join(bot, "memory", "workspace.ts"), memoryProvider("workspace", entries, "Standing decision", "workspace"));
247
- if (name === "main" && isKitDashSchedule(bot)) writeFileSync(join(bot, DASH_SCHEDULE_FILE), dashScheduleStub(entries));
248
+ if (name === coordinator && isKitDashSchedule(bot)) writeFileSync(join(bot, DASH_SCHEDULE_FILE), dashScheduleStub(entries));
248
249
  // A folder the roster doesn't list has no place in the team, and a function can't read the file the stub would.
249
250
  if (definition.agents.some((a) => a.name === name)) writeFileSync(join(bot, "memory", "team.ts"), teamProvider(architectureText(definition.data, name)));
250
251
  else rmSync(join(bot, "memory", "team.ts"), { force: true });
@@ -28,6 +28,7 @@
28
28
  // tests (plain node) import the same module.
29
29
 
30
30
  import { createHash } from "node:crypto";
31
+ import { coordinatorOf } from "../workspace.mjs";
31
32
  import { lastSaid, said, threadBrief, briefText } from "./thread-brief.mjs";
32
33
  import { busyBasis, factsText, laneFacts, factsKey, paragraphs, reasonLine } from "./lane-facts.mjs";
33
34
 
@@ -69,7 +70,7 @@ export function pathsInProject(t, snap) {
69
70
  export function pathQuestion(paths) {
70
71
  if (!paths.length) return null;
71
72
  const criteria = Object.fromEntries(paths.map((u) => [u.id, `Path ${u.id}: "${u.title}". ${u.summary} Open threads: ${u.count}.`]));
72
- criteria[NEW_PATH] = "No listed path fits this work, even read broadly; main must name a new path.";
73
+ criteria[NEW_PATH] = "No listed path fits this work, even read broadly; the coordinator must name a new path.";
73
74
  return { type: "choice", instructions: "Which path covers this thread's work? Paths group threads by feature or effort at any depth. A bug, polish or follow-up belongs to its area's existing path. Prefer an existing path where it fits; choose new only when none fits.", criteria };
74
75
  }
75
76
 
@@ -230,7 +231,7 @@ export function newPathNotice(threads, snap) {
230
231
  for (const t of threads) byProject.set(t.tags?.project ?? "", [...(byProject.get(t.tags?.project ?? "") ?? []), t]);
231
232
  const brief = (t) => [`- ${t.id}`, ...briefText(threadBrief(t, snap)).split("\n").map((l) => ` ${l}`)];
232
233
  return [
233
- `@main, these are every unfiled thread in each project. Group each project's work in one go from its briefs, using read_thread when needed. Put each thread on a listed path where one fits, or on a new project/feature path, with update_thread (threadId and path only, including threads you aren't in). Merge near-duplicate paths with move_path. Never move a human-locked path.`,
234
+ `@${coordinatorOf(snap?.agents)}, these are every unfiled thread in each project. Group each project's work in one go from its briefs, using read_thread when needed. Put each thread on a listed path where one fits, or on a new project/feature path, with update_thread (threadId and path only, including threads you aren't in). Merge near-duplicate paths with move_path. Never move a human-locked path.`,
234
235
  ...[...byProject].flatMap(([p, ts]) => {
235
236
  const paths = pathsInProject(ts[0], snap);
236
237
  return [
@@ -253,14 +254,14 @@ const asking = (lane) => lane === "needs-you" || lane === "ready";
253
254
  // it in one write (hub.dashEntries), so a verdict and its notice are kept
254
255
  // together or not at all, and a failed write is judged afresh next pass
255
256
  // against the entry as it still is.
256
- export function asksNotice(e, t) {
257
+ export function asksNotice(e, t, coordinator) {
257
258
  const was = t.entry?.lane;
258
259
  if (was === e.lane || (asking(was) && asking(e.lane))) return null;
259
260
  const link = `[${String(t.title ?? t.id).replace(/[[\]]/g, "")}](${t.id})`;
260
261
  const line = asking(e.lane) ? `needs you: ${link}, ${e.reason}` : asking(was) ? `no longer asking you: ${link}` : null;
261
262
  if (!line) return null;
262
263
  const key = `asks-${createHash("sha256").update(`${e.threadId}:${e.lane}:${e.basis}`).digest("hex").slice(0, 32)}`;
263
- return { schedule: "dash", text: `@main dash: ${line}`, clientId: key };
264
+ return { schedule: "dash", text: `@${coordinator} dash: ${line}`, clientId: key };
264
265
  }
265
266
 
266
267
  // The `new` answers of a pass go to main in one notice per project (deduped
@@ -313,7 +314,7 @@ export async function runClassify(hub, { classify = async () => null, max = CLAS
313
314
  if (d.path) picks.push([t, d.path]);
314
315
  const own = humans.has(t.entry?.importanceBy);
315
316
  const entry = { threadId: t.id, ...(own || d.importance === undefined ? {} : { importance: d.importance }), lane: d.lane, reason: d.reason, basis: t.basis };
316
- const notice = asksNotice(entry, t);
317
+ const notice = asksNotice(entry, t, coordinatorOf(s?.agents));
317
318
  try {
318
319
  await (notice ? hub.dashEntries([entry], [], notice) : hub.dashEntries([entry]));
319
320
  return 1;
@@ -201,7 +201,7 @@ export function planPrompt(cards, about, { human = "me", prev = null, now = Date
201
201
  const open = new Set(cards.map((c) => c.id));
202
202
  const was = prev ? [...prev.chunks.flatMap((c) => c.rows.map((r) => ({ id: r.id, chunk: c.title }))), ...(prev.later?.rows ?? []).map((r) => ({ id: r.id, chunk: "later" }))].filter((r) => open.has(r.id)) : null;
203
203
  return [
204
- `You are main, ${human}'s chief of staff, triaging ${human}'s open threads by hand, exactly as ${human} would if they read every one in full. The cards below are threads where a bot said something after ${human}'s last message.`,
204
+ `You are the coordinator, ${human}'s chief of staff, triaging ${human}'s open threads by hand, exactly as ${human} would if they read every one in full. The cards below are threads where a bot said something after ${human}'s last message.`,
205
205
  `For each card decide: is something actually waiting on ${human} (a question, a decision, an approval, a look, a sign-off only they can give), or not (a status report, a bot-to-bot handoff, already settled, or waiting on someone else)? Judge from the text of the ask, not from tags.`,
206
206
  `Then order every real ask the way a careful person would work through them, as a chief of staff briefing ${human}: one chunk per track, so ${human} switches context as little as possible. Each card's \`context\` is its track and repo. Put first what matters most and what is blocking others; weigh how taxing each one is (how much to read, diff size, how many decisions). The rule: never come back to a track. All asks of one track sit in its one chunk, or all in "later"; within a track, put quick yeses first and deep reads last. The track of the thread they answered last (justAnswered) goes first, and that thread, if something waits there, is its first row: the share of their messages that stay in the previous message's thread is in the facts. A track whose asks can wait goes to "later" as a whole, not a piece of it. Quick yeses that unblock bots go early.`,
207
207
  was ? [`The previous plan, in order (move a row only when a fact changed, and say why in "moved"):`, ...was.map((r) => `- ${r.id} (${r.chunk})`)].join("\n") : "There is no previous plan: \"moved\" is empty.",
@@ -11,6 +11,7 @@ const parse = (iso) => {
11
11
  };
12
12
  const link = (thread) => `[${String(thread?.title ?? thread?.id ?? "?").replace(/[[\]]/g, "")}](${thread?.id})`;
13
13
  import { said, lastSaid } from "./thread-brief.mjs";
14
+ import { coordinatorOf } from "../workspace.mjs";
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // dash: what changed since the last scan that could put something on the
@@ -180,7 +181,8 @@ function waitMet(item, snap, prs, nowMs) {
180
181
  if (w.thread) {
181
182
  const t = snap?.threads?.[w.thread];
182
183
  const since = parse(w.since) ?? 0;
183
- const m = t && [...(t.messages ?? [])].reverse().find((x) => said(x) && x.author !== "main" && (parse(x.at) ?? 0) > since);
184
+ const coordinator = coordinatorOf(snap?.agents);
185
+ const m = t && [...(t.messages ?? [])].reverse().find((x) => said(x) && x.author !== coordinator && (parse(x.at) ?? 0) > since);
184
186
  if (m) return `${link(t)} got a message from ${m.author}`;
185
187
  }
186
188
  if (w.until && (parse(w.until) ?? Infinity) <= nowMs) return `${w.until.slice(0, 16).replace("T", " ")} passed`;
@@ -207,6 +209,7 @@ export function dashScan(snap, saved, prs, nowMs, hashOf, skip = new Set()) {
207
209
  const lines = [...pr.lines];
208
210
  const threads = snap?.threads ?? {};
209
211
  const items = snap?.dash?.items ?? [];
212
+ const coordinator = coordinatorOf(snap?.agents);
210
213
  const legacy = saved && saved.na !== undefined; // the short-lived time-based form: start over
211
214
 
212
215
  // Asks, by identity. A thread a bot is working in keeps its previous entry
@@ -243,11 +246,11 @@ export function dashScan(snap, saved, prs, nowMs, hashOf, skip = new Set()) {
243
246
  if (!t) continue;
244
247
  const working = Object.values(t.status ?? {}).includes("working");
245
248
  let was = legacy ? undefined : saved?.d?.[item.threadId];
246
- // An item new since the last scan: its baseline is the thread as main
247
- // saw it when adding it, so anything said since (by anyone but main) counts.
249
+ // An item new since the last scan: its baseline is the thread as the coordinator
250
+ // saw it when adding it, so anything said since (by anyone else) counts.
248
251
  if (was === undefined && saved && item.addedAt) {
249
252
  const since = parse(item.addedAt) ?? Infinity;
250
- was = (t.messages ?? []).some((x) => said(x) && x.author !== "main" && (parse(x.at) ?? 0) > since) ? "~" : undefined;
253
+ was = (t.messages ?? []).some((x) => said(x) && x.author !== coordinator && (parse(x.at) ?? 0) > since) ? "~" : undefined;
251
254
  }
252
255
  // Working: judged once it settles. A thread first seen working is marked
253
256
  // "~", so its first settled message counts as moving on.
@@ -259,7 +262,7 @@ export function dashScan(snap, saved, prs, nowMs, hashOf, skip = new Set()) {
259
262
  d[item.threadId] = t.archived ? "archived" : (m?.id ?? "-");
260
263
  if (was === undefined || was === d[item.threadId]) continue;
261
264
  if (t.archived) lines.push(`archived: ${link(t)} is still on the Dash`);
262
- else if (m && !askingNow.has(item.threadId) && !noLonger.has(item.threadId) && m.author !== "main") lines.push(`moved on: ${link(t)} (${item.ball}), ${m.author}: "${excerpt(m.text, 80)}"`);
265
+ else if (m && !askingNow.has(item.threadId) && !noLonger.has(item.threadId) && m.author !== coordinator) lines.push(`moved on: ${link(t)} (${item.ball}), ${m.author}: "${excerpt(m.text, 80)}"`);
263
266
  }
264
267
 
265
268
  const w = {};
@@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
19
19
  import { NoValidTokenError, UserAuthorizationRequiredError, getToken } from "@vercel/connect";
20
20
  import { GITHUB_CONNECTOR, rosterSubject } from "../github-connector.mjs";
21
21
  import { runOnce } from "./hub.mjs";
22
+ import { coordinatorOf } from "../workspace.mjs";
22
23
  import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
23
24
 
24
25
  // The hub gives a schedule 60 s (registry/server.mjs
@@ -158,7 +159,7 @@ export async function runDash(hub, { probe, token = githubToken, now = Date.now(
158
159
  // The note rides on a post that's going out anyway: a skip alone doesn't
159
160
  // wake main (a lasting outage shows as the Dash going stale).
160
161
  const body = lines.length ? (lines.length === 1 ? lines[0] : `${lines.length} changes\n${lines.map((l) => `- ${l}`).join("\n")}`) : null;
161
- const text = body ? `@main dash: ${body}${note ? `\n(${note})` : ""}` : null;
162
+ const text = body ? `@${coordinatorOf(snap?.agents)} dash: ${body}${note ? `\n(${note})` : ""}` : null;
162
163
  return { posts: text ? [text] : [], state: changed ? { ...state, ...(saved?.pass ? { pass: saved.pass } : {}) } : saved, changed };
163
164
  }, { now });
164
165
  if (ok) await hub.dashChecked(prFacts);
package/schedules/hub.mjs CHANGED
@@ -1,14 +1,15 @@
1
- // main's schedules talk to the registry over its HTTP API
1
+ // The coordinator's schedules talk to the registry over its HTTP API
2
2
  // (ROSTER_REGISTRY_URL): a handler's own output (`to()`) never reaches the
3
3
  // hub, so they read /api/state and post into their schedule's thread
4
- // (sch-main-<id>) as "system", which wakes whoever the text @-mentions.
4
+ // (sch-<coordinator>-<id>) as "system", which wakes whoever the text @-mentions.
5
5
  //
6
6
  // Plain JS on purpose: the schedules (TypeScript, bundled by eve) and the
7
7
  // tests (plain node) import the same module.
8
8
  import { createHash } from "node:crypto";
9
9
  import { registryBase, registryFetch } from "../registry-fetch.mjs";
10
+ import { coordinatorOf } from "../workspace.mjs";
10
11
 
11
- export function createHub({ base = registryBase(), agent = "main", fetchImpl = fetch, log = console } = {}) {
12
+ export function createHub({ base = registryBase(), agent, fetchImpl = fetch, log = console } = {}) {
12
13
  const call = async (path, init = {}) => {
13
14
  const res = await registryFetch(path, { ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) } }, { base, fetchImpl, deadlineMs: 10_000 });
14
15
  const text = await res.text();
@@ -25,32 +26,36 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
25
26
  };
26
27
  // The run the hub is following for this process's handler (`track`), so a commit says which run it belongs to.
27
28
  let run = null;
28
- const threadOf = (id) => `sch-${agent}-${id.replaceAll("/", ".")}`;
29
- const statePath = (id) => `/api/schedules/${encodeURIComponent(agent)}/${id.split("/").map(encodeURIComponent).join("/")}/state`;
29
+ // The schedules run as the coordinator; with no `agent` given, its name is read once from the hub's roster.
30
+ let named = agent ? Promise.resolve(agent) : null;
31
+ const who = () => (named ??= must("/api/state").then((s) => coordinatorOf(s?.agents)));
32
+ const threadOf = async (id) => `sch-${await who()}-${id.replaceAll("/", ".")}`;
33
+ const statePath = async (id) => `/api/schedules/${encodeURIComponent(await who())}/${id.split("/").map(encodeURIComponent).join("/")}/state`;
30
34
  return {
31
35
  snapshot: () => must("/api/state"),
32
36
  roster: () => must("/api/roster"),
33
- load: async (id) => (await must(statePath(id)))?.state ?? null,
37
+ load: async (id) => (await must(await statePath(id)))?.state ?? null,
34
38
  // The state and when it was saved (the version a run decided on).
35
39
  loadVersioned: async (id) => {
36
- const body = await must(statePath(id));
40
+ const body = await must(await statePath(id));
37
41
  return { state: body?.state ?? null, savedAt: body?.savedAt ?? null, paused: body?.paused === true };
38
42
  },
39
- save: (id, state) => must(statePath(id), { method: "PUT", body: JSON.stringify({ state }) }),
43
+ save: async (id, state) => must(await statePath(id), { method: "PUT", body: JSON.stringify({ state }) }),
40
44
  // Save `state` (if given: { state }) and post `posts` in one step.
41
- commit: (id, withState, posts) => must(statePath(id), { method: "PUT", body: JSON.stringify({ ...withState, posts, ...(run?.id === id ? { run: run.run } : {}) }) }),
45
+ commit: async (id, withState, posts) => must(await statePath(id), { method: "PUT", body: JSON.stringify({ ...withState, posts, ...(run?.id === id ? { run: run.run } : {}) }) }),
42
46
  // One dispatch of the schedule `id`: the hub is told it started (it answers with the run's id and whether the
43
47
  // schedule is paused, which skips `fn`) and how it ended, so the hub's run history is what the handler did. A hub
44
48
  // that doesn't keep run history (the local one) answers 404 to the start, and `fn` runs untracked.
45
49
  track: async (id, fn) => {
46
- const started = await call(`${statePath(id).slice(0, -"/state".length)}/runs`, { method: "POST", body: "{}" }).catch(() => null);
50
+ const runs = `${(await statePath(id)).slice(0, -"/state".length)}/runs`;
51
+ const started = await call(runs, { method: "POST", body: "{}" }).catch(() => null);
47
52
  if (!started?.ok) return fn();
48
53
  if (started.body.paused) {
49
- log.log(`schedule ${agent}/${id} skipped: paused`);
54
+ log.log(`schedule ${await who()}/${id} skipped: paused`);
50
55
  return;
51
56
  }
52
57
  run = { id, run: started.body.run };
53
- const end = (error) => call(`${statePath(id).slice(0, -"/state".length)}/runs/${encodeURIComponent(run.run)}/end`, { method: "POST", body: JSON.stringify(error ? { error: error.slice(0, 2000) } : {}) }).catch(() => null);
58
+ const end = (error) => call(`${runs}/${encodeURIComponent(run.run)}/end`, { method: "POST", body: JSON.stringify(error ? { error: error.slice(0, 2000) } : {}) }).catch(() => null);
54
59
  try {
55
60
  const result = await fn();
56
61
  await end();
@@ -63,23 +68,23 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
63
68
  }
64
69
  },
65
70
  // A one-off post outside a run (runOnce commits through `commit`).
66
- post: (id, text, key) => must(`/api/threads/${encodeURIComponent(threadOf(id))}/messages`, { method: "POST", body: JSON.stringify({ author: "system", text, ...(key ? { clientId: key } : {}) }) }),
71
+ post: async (id, text, key) => must(`/api/threads/${encodeURIComponent(await threadOf(id))}/messages`, { method: "POST", body: JSON.stringify({ author: "system", text, ...(key ? { clientId: key } : {}) }) }),
67
72
  // The Dash was scanned now (hosted-hub/src/domain/dash.mjs checkedAt), changed or not.
68
73
  // `prFacts` ({ "owner/repo#12": { head, ci } }): what the scan's GitHub query said of the PRs reviews are about.
69
- dashChecked: (prFacts = {}) => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: agent, prFacts }) }),
74
+ dashChecked: async (prFacts = {}) => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: await who(), prFacts }) }),
70
75
  // A track's summary (set_summary's endpoint, as main).
71
- setSummary: (path, text) => must("/api/summaries", { method: "POST", body: JSON.stringify({ by: agent, scope: `path:${path}`, text }) }),
76
+ setSummary: async (path, text) => must("/api/summaries", { method: "POST", body: JSON.stringify({ by: await who(), scope: `path:${path}`, text }) }),
72
77
  // A classifier pass finished (hosted-hub/src/domain/dash.mjs classifiedAt).
73
- dashClassified: () => must("/api/dash/classified", { method: "POST", body: JSON.stringify({ by: agent }) }),
78
+ dashClassified: async () => must("/api/dash/classified", { method: "POST", body: JSON.stringify({ by: await who() }) }),
74
79
  // Dash entries from the classifier (hosted-hub/src/domain/dash.mjs), merged per field.
75
80
  // File an unfiled thread into a track (the classifier's pick), as a bot:
76
81
  // the hub refuses a thread that's already filed and doesn't lock it.
77
- fileThread: (threadId, path) => must(`/api/threads/${encodeURIComponent(threadId)}/track`, { method: "POST", body: JSON.stringify({ by: agent, path }) }),
82
+ fileThread: async (threadId, path) => must(`/api/threads/${encodeURIComponent(threadId)}/track`, { method: "POST", body: JSON.stringify({ by: await who(), path }) }),
78
83
  // The plan main's pass made (wire DashPlanIn, checked in hosted-hub/src/domain/dash.mjs normalizePlan); the hub keeps it as the newest.
79
- dashPlan: (basis, plan) => must("/api/dash/plan", { method: "POST", body: JSON.stringify({ by: agent, basis, plan }) }),
84
+ dashPlan: async (basis, plan) => must("/api/dash/plan", { method: "POST", body: JSON.stringify({ by: await who(), basis, plan }) }),
80
85
  // `notice` ({ schedule, text, clientId? }): posted in that schedule's
81
86
  // thread in the same write as the entries, or neither is kept.
82
- dashEntries: (entries, remove = [], notice = null) => must("/api/dash/entries", { method: "POST", body: JSON.stringify({ by: agent, entries, remove, ...(notice ? { notice } : {}) }) }),
87
+ dashEntries: async (entries, remove = [], notice = null) => must("/api/dash/entries", { method: "POST", body: JSON.stringify({ by: await who(), entries, remove, ...(notice ? { notice } : {}) }) }),
83
88
  };
84
89
  }
85
90
 
package/self-restart.mjs CHANGED
@@ -16,26 +16,27 @@
16
16
 
17
17
  export const SELF_STOP_COMMANDS = ["stop", "restart"];
18
18
 
19
- // main restarts the hub with its restart_hub tool (the registry hands it to
20
- // a detached `hub restart` and re-delivers main's turn afterwards); every
21
- // other bot asks main.
22
- export function selfStopMessage(command = "restart", agent) {
19
+ // The coordinator (`coordinator`: its name) restarts the hub with its restart_hub tool (the
20
+ // registry hands it to a detached `hub restart` and re-delivers its turn afterwards); every
21
+ // other bot asks it.
22
+ export function selfStopMessage(command = "restart", agent, coordinator) {
23
23
  const who = agent ? ` (${agent})` : "";
24
24
  const why =
25
25
  command === "stop"
26
26
  ? `stops the whole hub, and this bot's own server${who} with it`
27
27
  : `replaces the registry this bot reports through, and respawns this bot's own server${who} if its code, env or config changed`;
28
28
  const head = `refused: \`hub ${command}\` ${why}, so it could kill the turn running it and that turn would never report back. Nothing was run.\n`;
29
- if (agent === "main") {
29
+ if (coordinator && agent === coordinator) {
30
30
  return (
31
31
  head +
32
32
  ` - The whole hub: call the \`restart_hub\` tool. It runs the restart outside your server and re-delivers this turn if the restart cuts it off.\n` +
33
33
  ` - One bot (its config, role or code changed): \`restart_agent <name>\`.`
34
34
  );
35
35
  }
36
+ if (!coordinator) return head + ` - Ask the human in the thread to run \`hub ${command}\` from a terminal.`;
36
37
  return (
37
38
  head +
38
- ` - Ask main in the thread: main restarts the whole hub (\`restart_hub\`), or just one bot (\`restart_agent <name>\`, which waits until that bot is idle and re-delivers any turn it cuts off).`
39
+ ` - Ask ${coordinator} in the thread: ${coordinator} restarts the whole hub (\`restart_hub\`), or just one bot (\`restart_agent <name>\`, which waits until that bot is idle and re-delivers any turn it cuts off).`
39
40
  );
40
41
  }
41
42
 
package/sync-kit.d.mts CHANGED
@@ -5,7 +5,7 @@ export type KitChange = {
5
5
  report: { bot: string; file: string; reason: string }[];
6
6
  instructions: Record<string, string>;
7
7
  };
8
- export function syncKit(input: { bots: KitBot[]; modules: Iterable<string> }): KitChange;
8
+ export function syncKit(input: { bots: KitBot[]; modules: Iterable<string>; coordinator: string }): KitChange;
9
9
  export function isKitFile(file: string): boolean;
10
10
  export function readBotFiles(agentDir: string): Record<string, string>;
11
11
  export function applyKit(change: Pick<KitChange, 'write' | 'delete'>, dirOf: (bot: string) => string): void;
package/sync-kit.mjs CHANGED
@@ -52,13 +52,14 @@ function kitImports(text) {
52
52
  * that ships the result should refuse it), instructions { [bot]: source }.
53
53
  * Only a plain kit re-export is deleted or replaced.
54
54
  */
55
- export function syncKit({ bots, modules }) {
55
+ export function syncKit({ bots, modules, coordinator }) {
56
+ if (coordinator !== null && (typeof coordinator !== "string" || !coordinator)) throw new Error(`syncKit needs the coordinator's name, or null for a team with none (coordinatorOf in workspace.mjs), not ${JSON.stringify(coordinator)}`);
56
57
  const kit = new Set(modules);
57
58
  const change = { write: [], delete: [], report: [], instructions: {} };
58
59
  for (const { name: bot, runsOn, files: found, subagents = [] } of bots) {
59
60
  if (runsOn !== "local" && runsOn !== "hosted") throw new Error(`${bot}: runsOn must be "local" or "hosted", not ${JSON.stringify(runsOn)}`);
60
61
  const local = runsOn === "local";
61
- const main = bot === "main";
62
+ const main = bot === coordinator;
62
63
  const stubs = main ? MAIN_KIT_FILES : KIT_FILES;
63
64
  const files = { ...found };
64
65
  const has = (file) => typeof files[file] === "string";
@@ -1,6 +1,6 @@
1
- - **Team.** `create_agent` adds a bot to the workspace; if nobody on the team covers the work well, create a specialist (name, model, role, instructions), then route to it. Give planners and reviewers `preset: "read-only"` so they can't edit files. `remove_agent` removes a bot from the team and shuts it down (name); use it when the human asks. It can't remove main or humans.
1
+ - **Team.** `create_agent` adds a bot to the workspace; if nobody on the team covers the work well, create a specialist (name, model, role, instructions), then route to it. Give planners and reviewers `preset: "read-only"` so they can't edit files. `remove_agent` removes a bot from the team and shuts it down (name); use it when the human asks. It can't remove you (the coordinator) or humans.
2
2
  - **Memory.** A new memory entry ships in the next workspace sync PR; each bot recalls it on its next turn, as a `Standing decision <id>:` or `Your standing decision <id>:` message.
3
3
  - **Summaries.** Your `dash` schedule rewrites the summary of every track with a thread that has new messages (one model call per track, from the old summary and those threads), every 8 minutes and as soon as a bot's turn ends; you needn't, but a summary you write with `set_summary` stands until a thread on the track moves again. `list_threads` returns thread and track summaries, and the classifier files threads by them.
4
4
  - **Classifier.** Your `dash` schedule classifies every non-deferred thread, busy ones included, whenever its facts change (at most once a minute) and otherwise on a growing backoff, and writes the entries itself. It reads the thread; whether it waits on the human is the model's call. You don't rewrite the Dash on each tick.
5
- - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in `sch-main-dash` only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): change a `lane` (with its `reason`) the classifier got wrong, raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
5
+ - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in your `dash` schedule thread (`sch-<your name>-dash`) only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): change a `lane` (with its `reason`) the classifier got wrong, raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
6
6
  - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `update_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
@@ -9,7 +9,7 @@
9
9
  - **Figure it out before you ask**
10
10
  - Read the code, run it, check the logs and the thread history first.
11
11
  - Most problems are already solved: find the standard tool or prior art before designing anything.
12
- - For a specialty you don't have, ask main or the bot whose role fits before you ask the human.
12
+ - For a specialty you don't have, ask the coordinator or the bot whose role fits before you ask the human.
13
13
  - Ask the human only for what only they have: a decision between real options, a preference, access, or a look. Ask once, with what you found and the option you recommend.
14
14
  - Act freely on what's reversible and yours (a branch, a draft PR, a probe). Ask before what's irreversible or seen by others (a merge, a deploy, a post outside the workspace).
15
15
  - Why: every question costs the human a context switch, and most answers are in the code or the history.
@@ -1,4 +1,4 @@
1
- You are **main**, the human's chief of staff. The human talks to you first, and you make sure what they ask for gets done. You scope it, put the right bot on it, and stay on it across turns and threads until it lands. A quick question you can settle from what you already know ("where is X?") you answer directly, without opening a thread. You are relentless. You work long stretches without being prodded. When something blocks a bot (a missing grant, an answer only the human has, a bot going in circles), you clear it yourself, put the right bot on it, or get it from the one person who can. Every turn, you look for the next step that moves something forward. You work between the bots that do the work, never as one of them: you don't build or review, because a chief of staff inside one task stops watching the others.
1
+ You are the **coordinator**, the human's chief of staff. The human talks to you first, and you make sure what they ask for gets done. You scope it, put the right bot on it, and stay on it across turns and threads until it lands. A quick question you can settle from what you already know ("where is X?") you answer directly, without opening a thread. You are relentless. You work long stretches without being prodded. When something blocks a bot (a missing grant, an answer only the human has, a bot going in circles), you clear it yourself, put the right bot on it, or get it from the one person who can. Every turn, you look for the next step that moves something forward. You work between the bots that do the work, never as one of them: you don't build or review, because a chief of staff inside one task stops watching the others.
2
2
 
3
3
  **Take the ask.** First look for an open thread on the same work (`list_threads`) and continue there. Otherwise, `create_thread` with a `path` and an `issueType` already in use. A path is `project/track`: reuse the area's path, and start a new one only for a new effort, named for the feature. The thread's first message mentions only the bot who starts. It gives the goal, the constraints, what done looks like, and "validate with the human" when the scope needs their yes. Building waits for that yes. Route by fit: unclear scope, many parts or real risk go to a planner, and a clear, small change goes straight to a builder. A thread the human starts reaches you untitled: file it with one `update_thread`, then route it inside that thread. Tell the human in one or two lines what you opened and who's on it.
4
4
 
@@ -18,7 +18,7 @@ export default defineTool({
18
18
  const res = await registryFetch(`/api/threads`, {
19
19
  method: "POST",
20
20
  headers: { "content-type": "application/json" },
21
- body: JSON.stringify({ title, message, author: "main", sessionId: ctx.session.id, focus, project, issueType, projectLabel, issueTypeLabel, path, pathLabel }),
21
+ body: JSON.stringify({ title, message, sessionId: ctx.session.id, focus, project, issueType, projectLabel, issueTypeLabel, path, pathLabel }),
22
22
  });
23
23
  if (!res.ok)
24
24
  throw new Error(`create_thread failed: ${res.status} ${await res.text()}`);
package/vercel.mjs CHANGED
@@ -5,12 +5,17 @@
5
5
  // Vercel build: elsewhere this file's cwd is a person's checkout.
6
6
  import { withEve } from "eve/vercel";
7
7
  import { prepareDeployment } from "./prepare.mjs";
8
+ import { coordinatorOf, readWorkspaceFile } from "./workspace.mjs";
8
9
 
9
- if (process.env.VERCEL) prepareDeployment(process.cwd());
10
+ const dir = process.cwd();
11
+ if (process.env.VERCEL) prepareDeployment(dir);
10
12
 
11
13
  // Each agents/<name>/agent is its own service at /eve/<name>/v1/*. eve
12
- // publishes only its own transport routes, so main's /hub/manifest channel
13
- // route (agents/main/agent/channels/manifest.ts) is routed here.
14
+ // publishes only its own transport routes, so the coordinator's /hub/manifest
15
+ // channel route (agents/<coordinator>/agent/channels/manifest.ts, which
16
+ // prepare.mjs writes) is routed here.
17
+ const coordinator = coordinatorOf(readWorkspaceFile(dir).agents);
18
+ if (!coordinator) throw new Error(`${dir}: hub.workspace.json names no coordinator to serve /hub/manifest`);
14
19
  export default await withEve({
15
- routes: [{ src: "^/hub/manifest$", destination: { type: "service", service: "eve-main" } }],
20
+ routes: [{ src: "^/hub/manifest$", destination: { type: "service", service: `eve-${coordinator}` } }],
16
21
  });
package/workspace.mjs CHANGED
@@ -58,6 +58,19 @@ export const KINDS = ["coordinator", "planner", "builder", "reviewer", "git-ops"
58
58
 
59
59
  export class WorkspaceError extends Error {}
60
60
 
61
+ // The coordinator's name: the one bot whose `kind` is "coordinator", or null when the team has
62
+ // none (a workspace of only its own bots is valid). Its name is only a name (new workspaces call
63
+ // it `chief`, older ones `main`), so nothing decides by it.
64
+ export function coordinatorOf(agents) {
65
+ const list = agents ?? [];
66
+ const found = list.filter((a) => a.kind === "coordinator");
67
+ if (found.length > 1) throw new WorkspaceError(`${WORKSPACE_FILE}: expected at most one agent with "kind": "coordinator", found ${found.length} (${found.map((a) => a.name).join(", ")})`);
68
+ if (found.length) return found[0].name;
69
+ // Manifests scaffolded before kinds existed (#622) set no `kind` on `main`; it stays the coordinator
70
+ // after kind-tagged teammates join. A `main` given another kind is not one.
71
+ return list.some((a) => a.name === "main" && !("kind" in a)) ? "main" : null;
72
+ }
73
+
61
74
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
62
75
  const fail = (file, message) => {
63
76
  throw new WorkspaceError(`${file}: ${message}`);