eve-hub 0.16.17 → 0.16.19

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
  /**
@@ -1,2 +1,2 @@
1
- declare const _default: import("eve").DynamicSentinel<import("eve/connections").DynamicConnectionResult, import("eve").DynamicContext>;
1
+ declare const _default: import("eve/connections").DynamicSentinel<import("eve/connections").DynamicConnectionResult, import("eve/connections").DynamicContext>;
2
2
  export default _default;
@@ -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.`,
@@ -59,13 +59,13 @@ export function hostedContext({ bot, kit, removed, deployment: d, git = false },
59
59
  ? "- **Changing it:** commit to that repository; Vercel builds and deploys the commit."
60
60
  : "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
61
61
  git
62
- ? "- **GitHub:** goes through the `git__gh` tool (`git`, `gh`, `gh-signed-commit`), which runs each command with the human's own GitHub token, swapped in at the sandbox firewall for that command, so pushes, pull requests and commits are theirs. The shell itself holds no credential, so plain `git` and `gh` in bash cannot authenticate."
62
+ ? "- **GitHub:** goes through the `git__gh` tool (`git`, `gh`, `gh-signed-commit`), which runs each command with the human's own GitHub token, swapped in at the sandbox firewall for that command, so pushes, pull requests and commits are theirs. The shell itself holds no credential, so plain `git` and `gh` in bash cannot authenticate. Every command declares one repository in the workspace's org. Authenticated `git fetch`, `pull`, `push` and `ls-remote` name that repository's URL as the remote, never `origin` (`git push https://github.com/owner/name.git HEAD:branch`, `git fetch https://github.com/owner/name.git +refs/heads/branch:refs/remotes/origin/branch`), and `gh-signed-commit` always takes `--repo`."
63
63
  : "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (eve__search, then eve__tool); otherwise tell the human this deployment has no GitHub access.",
64
64
  "- **Memory:** the standing decisions you recall are the `memory/` and `agents/<bot>/memory/` files, built into each deployment. An entry takes effect when the deployment that contains it is live, not on your next turn; with no repository it cannot last.",
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.17",
3
+ "version": "0.16.19",
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",
@@ -39,9 +39,9 @@
39
39
  "./update": "./update.mjs"
40
40
  },
41
41
  "peerDependencies": {
42
- "@vercel/connect": "^2.4.1",
42
+ "@vercel/connect": "^2.7.0",
43
43
  "@vercel/oidc": "4.0.0",
44
- "eve": "~0.88.0",
44
+ "eve": "~1.0.0-rc.3",
45
45
  "zod": "4.5.4"
46
46
  },
47
47
  "dependencies": {
@@ -55,6 +55,6 @@
55
55
  "web-tree-sitter": "0.27.1"
56
56
  },
57
57
  "hubPins": {
58
- "eve": "0.88.0"
58
+ "eve": "1.0.0-rc.3"
59
59
  }
60
60
  }
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";
@@ -53,37 +53,62 @@ import type { VercelSandboxSession } from "eve/sandbox/vercel";
53
53
  // wake-ups and bot handoffs included, runs as the workspace's human (channels/eve-roster-user).
54
54
  // The sandbox only sees a placeholder; this swaps the real token in at the sandbox firewall for
55
55
  // that command and removes it afterwards. All other egress stays open.
56
+ const ORG = ${JSON.stringify(org.toLowerCase())};
57
+
58
+ // The firewall sees the request Git really sends, after any \`insteadOf\` or \`-c\` rewrite that
59
+ // \`authorize\` can't see, so it swaps the token in only for paths under the workspace's org.
60
+ // Anywhere else the placeholder goes out and GitHub answers 401. api.github.com keeps eve's rule:
61
+ // \`/graphql\` and \`/user\` carry no owner.
62
+ const ORG_PATHS: Record<string, string> = {
63
+ "github.com": \`(?i)^/\${ORG}/\`,
64
+ "uploads.github.com": \`(?i)^/repos/\${ORG}/\`,
65
+ };
66
+ const scoped = (rules: Parameters<GitHubLeaseBroker>[1]) =>
67
+ rules && Object.fromEntries(Object.entries(rules).map(([host, list]) => [
68
+ host,
69
+ host in ORG_PATHS ? list.map((rule) => ({ ...rule, match: { ...rule.match, path: { regex: ORG_PATHS[host] } } })) : list,
70
+ ]));
56
71
  const broker: GitHubLeaseBroker = async (sandbox, rules) => {
57
72
  if (!("setNetworkPolicy" in sandbox)) throw new Error("GitHub credential leases need a Vercel Sandbox");
58
- await (sandbox as VercelSandboxSession).setNetworkPolicy({ allow: { "*": [], ...rules } } as SandboxNetworkPolicy);
73
+ await (sandbox as VercelSandboxSession).setNetworkPolicy({ allow: { "*": [], ...scoped(rules) } } as SandboxNetworkPolicy);
59
74
  };
60
75
 
61
- const ORG = ${JSON.stringify(org.toLowerCase())};
76
+ // From eve 1.0, \`authorize\` is the only check: eve no longer refuses a command whose \`--repo\`,
77
+ // \`-R\` or \`gh repo clone|view|fork\` target differs from the declared repository, nor a \`git\`
78
+ // remote outside it, so this does. \`authorize\` can't read the sandbox, so a remote name such as
79
+ // \`origin\` can't be resolved: authenticated git names the declared repository's URL itself.
80
+ const GIT_REMOTE_COMMANDS = ["fetch", "ls-remote", "pull", "push"];
81
+ const targets = (argv: readonly string[]) => [
82
+ ...argv.flatMap((a, i) => (a === "--repo" || a === "-R" ? [argv[i + 1] ?? ""] : a.startsWith("--repo=") ? [a.slice(7)] : [])),
83
+ ...(argv[0] === "gh" && argv[1] === "repo" && ["clone", "view", "fork"].includes(argv[2] ?? "") && argv[3] && !argv[3].startsWith("-") ? [argv[3]] : []),
84
+ ];
85
+ const GITHUB_URL = /^(?:https:\\/\\/github\\.com\\/|git@github\\.com:|ssh:\\/\\/git@github\\.com\\/)/i;
86
+ const canonical = (repo: string) => repo.toLowerCase().replace(GITHUB_URL, "").replace(/\\/$/, "").replace(/\\.git$/, "");
87
+ const commandTargets = (argv: readonly string[]) =>
88
+ argv[0] === "git" ? (GIT_REMOTE_COMMANDS.includes(argv[1] ?? "") && GITHUB_URL.test(argv[2] ?? "") ? [argv[2]] : null)
89
+ : argv[0] === "gh-signed-commit" ? (targets(argv).length ? targets(argv) : null)
90
+ : targets(argv);
62
91
 
63
92
  export default git({
64
93
  github: {
65
94
  connector: ${JSON.stringify(GITHUB_CONNECTOR)},
66
95
  broker,
67
- authorize: ({ repository }) => repository.toLowerCase().startsWith(\`\${ORG}/\`),
96
+ authorize: ({ argv, repository }) => {
97
+ const found = commandTargets(argv);
98
+ return repository !== null && canonical(repository).startsWith(\`\${ORG}/\`) && found !== null && found.every((t) => canonical(t) === canonical(repository));
99
+ },
68
100
  principalType: "user",
69
101
  },
70
102
  });
71
103
  ` });
72
104
 
73
- // Bots that edit code: eve-code (apply_patch, grep, the review skills and its
74
- // read-only worker), with no `github` option. The worker runs on the roster's
75
- // subagent cap like every other subagent, not on eve's built-in default.
105
+ // Bots that edit code: eve-code (apply_patch, grep and the review skills), with
106
+ // no `github` option.
76
107
  export const CODE_BOTS = ["builder"];
77
- export const codeExtensionFiles = (bot) => ({
108
+ export const codeExtensionFiles = () => ({
78
109
  "extensions/code/extension.ts": `import code from "eve/extensions/code";
79
- import { bindRoster } from "eve-hub/roster-model";
80
- import { resolveSubagent } from "eve-hub/subagent-cap";
81
- import workspace from "../../../../../hub.workspace.json" with { type: "json" };
82
-
83
- const { agents, subagentCap } = bindRoster(workspace);
84
- const { model, reasoning } = resolveSubagent(agents, subagentCap, ${JSON.stringify(bot)}, "code__worker");
85
110
 
86
- export default code({ worker: { model, reasoning } });
111
+ export default code();
87
112
  `,
88
113
  });
89
114
 
@@ -153,10 +178,10 @@ export default dashSchedule({ decisions: () => decisions });
153
178
  // Replaces the kit's execution-context stub (the local page) with the hosted
154
179
  // page, for a bot the definition says is hosted. Its removed-tools list is read
155
180
  // from the bot's folder as pruned.
156
- function writeExecutionContext(bot, { bot: name, deployment }) {
157
- 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"));
158
183
  const removed = kitTools.filter((tool) => !existsSync(join(bot, "tools", `${tool}.ts`)));
159
- 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)) });
160
185
  mkdirSync(join(bot, "instructions"), { recursive: true });
161
186
  writeFileSync(join(bot, "instructions", "execution-context.ts"), `import { defineInstructions } from "eve/instructions";\n\nexport default defineInstructions({ content: ${JSON.stringify(section)} });\n`);
162
187
  }
@@ -168,12 +193,13 @@ export function prepareDeployment(dir) {
168
193
  if (existsSync(join(dir, MANIFEST_FILE))) return;
169
194
  validateMemory(dir);
170
195
  const definition = readWorkspaceFile(dir);
196
+ const coordinator = coordinatorOf(definition.agents);
171
197
  const resolved = { ...definition, agents: definition.agents.map((agent) => ({ ...agent, runsOn: agentRunsOn(definition, agent.name) })) };
172
198
  const dirOf = (name) => join(dir, "agents", name, "agent");
173
199
  const local = new Set(resolved.agents.filter((a) => a.runsOn === "local").map((a) => a.name));
174
200
  const folders = readdirSync(join(dir, "agents")).filter((name) => !local.has(name) && existsSync(join(dirOf(name), "agent.ts")));
175
201
  const tiersOf = (name) => Object.keys(definition.agents.find((a) => a.name === name)?.subagents ?? {});
176
- 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 });
177
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")}`);
178
204
  const deployment = deploymentFacts();
179
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).
@@ -185,8 +211,8 @@ export function prepareDeployment(dir) {
185
211
  removeWorkspaceAgent(dir, name);
186
212
  rmSync(join(dir, "agents", name), { recursive: true, force: true });
187
213
  }
188
- mkdirSync(join(dir, "agents", "main", "agent", "channels"), { recursive: true });
189
- 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);
190
216
  applyKit(change, dirOf);
191
217
  // Bots only: a declared subagent's agent.ts (agent/subagents/<tier>/) is not a bot and mounts none of these.
192
218
  for (const name of readdirSync(join(dir, "agents"))) {
@@ -196,7 +222,7 @@ export function prepareDeployment(dir) {
196
222
  // A user token carries all of the human's write access and can't be narrowed, so a read-only bot
197
223
  // gets none. A bot's own committed git.ts (a wider org list, say) is the workspace's and stays.
198
224
  const git = deployment.repoOwner && !existsSync(join(bot, READ_ONLY_FILE)) && !existsSync(join(bot, GIT_EXTENSION_FILE));
199
- for (const [stub, content] of Object.entries({ ...(git ? gitExtensionFiles(deployment.repoOwner) : {}), ...(code ? codeExtensionFiles(name) : {}) })) {
225
+ for (const [stub, content] of Object.entries({ ...(git ? gitExtensionFiles(deployment.repoOwner) : {}), ...(code ? codeExtensionFiles() : {}) })) {
200
226
  mkdirSync(dirname(join(bot, stub)), { recursive: true });
201
227
  writeFileSync(join(bot, stub), content);
202
228
  }
@@ -205,7 +231,7 @@ export function prepareDeployment(dir) {
205
231
  }
206
232
  for (const name of readdirSync(join(dir, "agents"))) {
207
233
  const bot = join(dir, "agents", name, "agent");
208
- writeExecutionContext(bot, { bot: name, deployment });
234
+ writeExecutionContext(bot, { bot: name, coordinator: name === coordinator, deployment });
209
235
  // The kit's extension mounts only on the hub's machine and is excluded from the kit tarball.
210
236
  // Other extensions, such as eve-code, stay.
211
237
  for (const item of readdirSync(bot, { recursive: true })) {
@@ -219,7 +245,7 @@ export function prepareDeployment(dir) {
219
245
  const botDir = join(dir, "agents", name);
220
246
  const { entries } = readMemory(dir, { folders: domainFolders(botDomains(dir, botDir)) });
221
247
  writeFileSync(join(bot, "memory", "workspace.ts"), memoryProvider("workspace", entries, "Standing decision", "workspace"));
222
- 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));
223
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.
224
250
  if (definition.agents.some((a) => a.name === name)) writeFileSync(join(bot, "memory", "team.ts"), teamProvider(architectureText(definition.data, name)));
225
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/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}`);