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 +6 -3
- package/connections/vercel-kit.d.ts +1 -1
- package/execution-context.mjs +5 -5
- package/kit-change.mjs +2 -2
- package/main-instructions.mjs +5 -2
- package/package.json +4 -4
- package/prepare.mjs +50 -24
- package/schedules/dash-classify.mjs +6 -5
- package/schedules/dash-plan.mjs +1 -1
- package/schedules/dash-rules.mjs +8 -5
- package/schedules/dash-run.mjs +2 -1
- package/schedules/hub.mjs +24 -19
- package/self-restart.mjs +7 -6
- package/sync-kit.d.mts +1 -1
- package/sync-kit.mjs +3 -2
- package/templates/execution-context-local-main.md +2 -2
- package/templates/execution.md +1 -1
- package/templates/main-instructions.md +1 -1
- package/tools/create_thread.js +1 -1
- package/workspace.mjs +13 -0
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);
|
|
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
|
-
|
|
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;
|
package/execution-context.mjs
CHANGED
|
@@ -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
|
-
...(
|
|
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), ...(
|
|
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) }));
|
package/main-instructions.mjs
CHANGED
|
@@ -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(
|
|
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.
|
|
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.
|
|
42
|
+
"@vercel/connect": "^2.7.0",
|
|
43
43
|
"@vercel/oidc": "4.0.0",
|
|
44
|
-
"eve": "~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.
|
|
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
|
-
|
|
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 }) =>
|
|
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
|
|
74
|
-
//
|
|
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 = (
|
|
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(
|
|
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(
|
|
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(
|
|
189
|
-
writeFileSync(join(
|
|
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(
|
|
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 ===
|
|
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;
|
|
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
|
-
|
|
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:
|
|
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;
|
package/schedules/dash-plan.mjs
CHANGED
|
@@ -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
|
|
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.",
|
package/schedules/dash-rules.mjs
CHANGED
|
@@ -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
|
|
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
|
|
247
|
-
// saw it when adding it, so anything said since (by anyone
|
|
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 !==
|
|
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 !==
|
|
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 = {};
|
package/schedules/dash-run.mjs
CHANGED
|
@@ -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 ?
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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 ${
|
|
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(`${
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
20
|
-
// a detached `hub restart` and re-delivers
|
|
21
|
-
// other bot asks
|
|
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 ===
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
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.
|
package/templates/execution.md
CHANGED
|
@@ -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
|
|
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 **
|
|
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
|
|
package/tools/create_thread.js
CHANGED
|
@@ -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,
|
|
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}`);
|