@staix/agent-hub 0.12.37 → 0.12.39
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/CHANGELOG.md +19 -0
- package/README.md +12 -6
- package/docs/agent-notes/adapters.md +19 -8
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +6 -3
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +3 -3
- package/docs/operations.md +45 -17
- package/docs/quickstart.md +19 -5
- package/docs/security.md +7 -3
- package/docs/smoke.md +22 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verification/2026-10-20-0.12.39.md +55 -0
- package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
- package/docs/verified.json +22 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1759 -437
- package/src/adapters/acp.ts +20 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/codex-review-isolation.ts +30 -16
- package/src/adapters/definitions/builtin-metadata.ts +8 -8
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-daemon-policy.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +4 -3
- package/src/adapters/jcode.ts +58 -1
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +29 -1
- package/src/adapters/registry.ts +16 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +83 -20
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +5 -4
- package/src/cli/output.ts +5 -0
- package/src/cli/preview.ts +2 -2
- package/src/hub/bus.ts +17 -6
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +182 -24
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/peers.ts +2 -2
- package/src/hub/reviewer-pool.ts +12 -6
- package/src/hub/routing.ts +3 -0
- package/src/hub/start-mode.ts +1 -1
- package/src/hub/tasks.ts +7 -0
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
7
|
+
import { realPath } from "../hub/project.ts";
|
|
8
|
+
|
|
9
|
+
export interface AgyMcpOptions {
|
|
10
|
+
projectRoot: string;
|
|
11
|
+
stateDir: string;
|
|
12
|
+
serverJs: string;
|
|
13
|
+
hookScript: string;
|
|
14
|
+
instanceId: string;
|
|
15
|
+
launchId: string;
|
|
16
|
+
peer?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PLUGIN_DIR = ".agents/plugins/agent-hub";
|
|
20
|
+
const PLUGIN_KEY = "agent-hub";
|
|
21
|
+
const MANIFEST_FILE = `${PLUGIN_DIR}/plugin.json`;
|
|
22
|
+
const MCP_FILE = `${PLUGIN_DIR}/mcp_config.json`;
|
|
23
|
+
const HOOKS_FILE = `${PLUGIN_DIR}/hooks.json`;
|
|
24
|
+
const FILES = [MANIFEST_FILE, MCP_FILE, HOOKS_FILE] as const;
|
|
25
|
+
export const AGY_PLUGIN_FILES = FILES;
|
|
26
|
+
const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
|
|
27
|
+
const MANAGED_DESCRIPTION = "Managed by ahub agy launcher.";
|
|
28
|
+
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
29
|
+
type JsonObject = Record<string, any>;
|
|
30
|
+
type Target = { path: string; relative: typeof FILES[number]; current?: Buffer; stat?: Stats; next: Buffer; changed: boolean };
|
|
31
|
+
type DirectoryIdentity = { path: string; stat: Stats };
|
|
32
|
+
type Plan = { file: string; tracked: boolean; changed: boolean; action: string; root: string; pluginDir: string; canonicalRoot: string; canonicalPluginDir?: string; directories?: DirectoryIdentity[]; targets: Target[] };
|
|
33
|
+
|
|
34
|
+
function object(value: unknown): value is JsonObject { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
35
|
+
function jsonFile(value: unknown, name: string): JsonObject {
|
|
36
|
+
if (!object(value)) throw new Error(`${name} must be a JSON object`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function parseJson(bytes: Buffer, name: string): JsonObject {
|
|
40
|
+
try { return jsonFile(JSON.parse(bytes.toString("utf8")), name); }
|
|
41
|
+
catch (error) { if (error instanceof SyntaxError) throw new Error(`${name} is invalid JSON`); throw error; }
|
|
42
|
+
}
|
|
43
|
+
function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
44
|
+
function shellUnquote(value: string): string | undefined {
|
|
45
|
+
if (!value.startsWith("'") || !value.endsWith("'")) return undefined;
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let index = 1; index < value.length - 1;) {
|
|
48
|
+
if (value.startsWith("'\\''", index)) { out += "'"; index += 4; }
|
|
49
|
+
else if (value[index] === "'") return undefined;
|
|
50
|
+
else out += value[index++];
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateOptions(options: AgyMcpOptions) {
|
|
56
|
+
const peer = options.peer ?? "agy";
|
|
57
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
|
|
58
|
+
for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
|
|
59
|
+
if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
|
|
60
|
+
}
|
|
61
|
+
for (const [name, value] of Object.entries({ instanceId: options.instanceId, launchId: options.launchId })) {
|
|
62
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
63
|
+
}
|
|
64
|
+
const root = resolve(options.projectRoot);
|
|
65
|
+
return { root, peer };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeDirectory(path: string): Stats | undefined {
|
|
69
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
70
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`${path} must be a project directory, not a link`);
|
|
71
|
+
return stat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeFile(path: string): { stat: Stats; bytes: Buffer } | undefined {
|
|
75
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
76
|
+
if (!stat) return undefined;
|
|
77
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_CONFIG_BYTES) throw new Error(`${path} must be a bounded regular file, not a link`);
|
|
78
|
+
const bytes = readFileSync(path);
|
|
79
|
+
if (bytes.length > MAX_CONFIG_BYTES) throw new Error(`${path} exceeds the configuration size limit`);
|
|
80
|
+
return { stat, bytes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pluginDirectories(root: string): DirectoryIdentity[] {
|
|
84
|
+
const directories: DirectoryIdentity[] = [];
|
|
85
|
+
for (const relative of [".", ".agents", ".agents/plugins", PLUGIN_DIR]) {
|
|
86
|
+
const path = relative === "." ? root : join(root, relative);
|
|
87
|
+
const stat = safeDirectory(path);
|
|
88
|
+
if (!stat) throw new Error(`${path} must be an existing project directory`);
|
|
89
|
+
directories.push({ path, stat });
|
|
90
|
+
}
|
|
91
|
+
return directories;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function verifyPluginDirectory(plan: Pick<Plan, "root" | "pluginDir" | "canonicalRoot" | "canonicalPluginDir" | "directories">, target: string): void {
|
|
95
|
+
if (!plan.canonicalPluginDir || !plan.directories) throw new Error("managed agy plugin directory identity is unavailable");
|
|
96
|
+
if (realPath(plan.root) !== plan.canonicalRoot || realPath(plan.pluginDir) !== plan.canonicalPluginDir || plan.canonicalPluginDir !== join(plan.canonicalRoot, PLUGIN_DIR)) {
|
|
97
|
+
throw new Error("managed agy plugin path changed; inspect it before writing");
|
|
98
|
+
}
|
|
99
|
+
for (const expected of plan.directories) {
|
|
100
|
+
const current = safeDirectory(expected.path);
|
|
101
|
+
if (!current || current.dev !== expected.stat.dev || current.ino !== expected.stat.ino) throw new Error("managed agy plugin directory changed; inspect it before writing");
|
|
102
|
+
}
|
|
103
|
+
if (!FILES.some(file => join(plan.root, file) === target)) {
|
|
104
|
+
throw new Error("atomic write target is outside the managed agy plugin files");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hookCommand(phase: typeof EVENTS[number], options: AgyMcpOptions, peer: string): string {
|
|
109
|
+
return `AGENTHUB_HOOK_EVENT='${phase}' AGENTHUB_INSTANCE_ID='${options.instanceId}' AGENTHUB_LAUNCH_ID='${options.launchId}' AGENTHUB_HOOK_PURPOSE='facts' AGENTHUB_PEER_ID=${shellQuote(peer)} AGENTHUB_STATE_DIR=${shellQuote(options.stateDir)} AGENTHUB_PROJECT_DIR=${shellQuote(resolve(options.projectRoot))} bun ${shellQuote(options.hookScript)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function desired(options: AgyMcpOptions, root: string, peer: string): Record<typeof FILES[number], JsonObject> {
|
|
113
|
+
const events: JsonObject = {};
|
|
114
|
+
for (const phase of EVENTS) {
|
|
115
|
+
if (phase === "PostInvocation") { events[phase] = []; continue; }
|
|
116
|
+
const command = { type: "command", command: hookCommand(phase, options, peer), timeout: 5 };
|
|
117
|
+
events[phase] = phase === "PreToolUse" || phase === "PostToolUse"
|
|
118
|
+
? [{ matcher: ".*", hooks: [command] }]
|
|
119
|
+
: [command];
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
[MANIFEST_FILE]: { name: PLUGIN_KEY, description: MANAGED_DESCRIPTION },
|
|
123
|
+
[MCP_FILE]: { mcpServers: { [PLUGIN_KEY]: { command: "bun", args: ["run", options.serverJs], env: { AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: options.stateDir, AGENTHUB_PROJECT_DIR: root } } } },
|
|
124
|
+
[HOOKS_FILE]: { [PLUGIN_KEY]: events },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseManagedHookCommand(command: unknown, phase: typeof EVENTS[number], options: AgyMcpOptions, peer: string): { instanceId: string; launchId: string } | undefined {
|
|
129
|
+
if (typeof command !== "string") return undefined;
|
|
130
|
+
const prefix = `AGENTHUB_HOOK_EVENT='${phase}' AGENTHUB_INSTANCE_ID='`;
|
|
131
|
+
const middle = `' AGENTHUB_LAUNCH_ID='`;
|
|
132
|
+
const suffix = `' AGENTHUB_HOOK_PURPOSE='facts' AGENTHUB_PEER_ID=${shellQuote(peer)} AGENTHUB_STATE_DIR=${shellQuote(options.stateDir)} AGENTHUB_PROJECT_DIR=${shellQuote(resolve(options.projectRoot))} bun `;
|
|
133
|
+
if (!command.startsWith(prefix)) return undefined;
|
|
134
|
+
const rest = command.slice(prefix.length);
|
|
135
|
+
const launchSplit = rest.indexOf(middle);
|
|
136
|
+
if (launchSplit < 1) return undefined;
|
|
137
|
+
const instanceId = rest.slice(0, launchSplit);
|
|
138
|
+
const afterInstance = rest.slice(launchSplit + middle.length);
|
|
139
|
+
const pathSplit = afterInstance.indexOf(suffix);
|
|
140
|
+
if (pathSplit < 1) return undefined;
|
|
141
|
+
const launchId = afterInstance.slice(0, pathSplit);
|
|
142
|
+
const hookScript = shellUnquote(afterInstance.slice(pathSplit + suffix.length));
|
|
143
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(instanceId) || !/^[A-Za-z0-9._:-]{1,128}$/.test(launchId) || !hookScript || !isAbsolute(hookScript) || !hookScript.endsWith("/src/cli/facts-hook.ts")) return undefined;
|
|
144
|
+
return { instanceId, launchId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function managedHookLaunch(value: JsonObject, options: AgyMcpOptions, peer: string): { instanceId: string; launchId: string } | undefined {
|
|
148
|
+
if (Object.keys(value).length !== 1 || !Object.hasOwn(value, PLUGIN_KEY)) return undefined;
|
|
149
|
+
const events = value[PLUGIN_KEY];
|
|
150
|
+
if (!object(events) || Object.keys(events).length !== EVENTS.length || EVENTS.some(phase => !Object.hasOwn(events, phase))) return undefined;
|
|
151
|
+
let identity: { instanceId: string; launchId: string } | undefined;
|
|
152
|
+
for (const phase of EVENTS) {
|
|
153
|
+
const rows = events[phase];
|
|
154
|
+
if (phase === "PostInvocation") { if (!Array.isArray(rows) || rows.length !== 0) return undefined; continue; }
|
|
155
|
+
if (!Array.isArray(rows) || rows.length !== 1 || !object(rows[0])) return undefined;
|
|
156
|
+
let command: unknown;
|
|
157
|
+
if (phase === "PreToolUse" || phase === "PostToolUse") {
|
|
158
|
+
const row = rows[0];
|
|
159
|
+
if (Object.keys(row).sort().join(",") !== "hooks,matcher" || row.matcher !== ".*" || !Array.isArray(row.hooks) || row.hooks.length !== 1 || !object(row.hooks[0])) return undefined;
|
|
160
|
+
command = row.hooks[0];
|
|
161
|
+
} else command = rows[0];
|
|
162
|
+
if (!object(command) || Object.keys(command).sort().join(",") !== "command,timeout,type" || command.type !== "command" || command.timeout !== 5) return undefined;
|
|
163
|
+
const found = parseManagedHookCommand(command.command, phase, options, peer);
|
|
164
|
+
if (!found || (identity && (identity.instanceId !== found.instanceId || identity.launchId !== found.launchId))) return undefined;
|
|
165
|
+
identity = found;
|
|
166
|
+
}
|
|
167
|
+
return identity;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function nextDocuments(current: Record<typeof FILES[number], JsonObject | undefined>, wanted: Record<typeof FILES[number], JsonObject>): Record<typeof FILES[number], JsonObject> {
|
|
171
|
+
const nextMcp = current[MCP_FILE] ? { ...current[MCP_FILE] } : {};
|
|
172
|
+
const currentServers = nextMcp.mcpServers;
|
|
173
|
+
const servers = currentServers === undefined ? {} : currentServers;
|
|
174
|
+
if (!object(servers)) throw new Error(`${MCP_FILE} mcpServers must be an object`);
|
|
175
|
+
nextMcp.mcpServers = { ...servers, [PLUGIN_KEY]: wanted[MCP_FILE].mcpServers[PLUGIN_KEY] };
|
|
176
|
+
return { [MANIFEST_FILE]: wanted[MANIFEST_FILE], [MCP_FILE]: nextMcp, [HOOKS_FILE]: wanted[HOOKS_FILE] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function internalPlan(options: AgyMcpOptions): Plan {
|
|
180
|
+
const { root, peer } = validateOptions(options);
|
|
181
|
+
const rootStat = safeDirectory(root);
|
|
182
|
+
if (!rootStat) throw new Error("project root must be an existing directory");
|
|
183
|
+
const canonicalRoot = realPath(root);
|
|
184
|
+
const pluginDir = join(root, PLUGIN_DIR);
|
|
185
|
+
let parentsPresent = true;
|
|
186
|
+
for (const relative of [".agents", ".agents/plugins", PLUGIN_DIR]) {
|
|
187
|
+
if (!parentsPresent) break;
|
|
188
|
+
parentsPresent = !!safeDirectory(join(root, relative));
|
|
189
|
+
}
|
|
190
|
+
let directories: DirectoryIdentity[] | undefined;
|
|
191
|
+
let canonicalPluginDir: string | undefined;
|
|
192
|
+
if (parentsPresent) {
|
|
193
|
+
directories = pluginDirectories(root);
|
|
194
|
+
canonicalPluginDir = realPath(pluginDir);
|
|
195
|
+
if (canonicalPluginDir !== join(canonicalRoot, PLUGIN_DIR)) throw new Error("managed agy plugin path traverses a symlink");
|
|
196
|
+
}
|
|
197
|
+
const snapshots = Object.fromEntries(FILES.map(relative => {
|
|
198
|
+
const path = join(root, relative);
|
|
199
|
+
const found = parentsPresent ? safeFile(path) : undefined;
|
|
200
|
+
return [relative, found];
|
|
201
|
+
})) as Record<typeof FILES[number], { stat: Stats; bytes: Buffer } | undefined>;
|
|
202
|
+
const current = {} as Record<typeof FILES[number], JsonObject | undefined>;
|
|
203
|
+
for (const relative of FILES) {
|
|
204
|
+
const found = snapshots[relative];
|
|
205
|
+
current[relative] = found ? parseJson(found.bytes, relative) : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const manifest = current[MANIFEST_FILE];
|
|
209
|
+
if (manifest && (Object.keys(manifest).sort().join(",") !== "description,name" || manifest.name !== PLUGIN_KEY || manifest.description !== MANAGED_DESCRIPTION)) {
|
|
210
|
+
throw new Error(`${MANIFEST_FILE} is a foreign agent-hub plugin manifest; it was not changed`);
|
|
211
|
+
}
|
|
212
|
+
const mcp = current[MCP_FILE];
|
|
213
|
+
if (mcp) {
|
|
214
|
+
if (Object.hasOwn(mcp, "mcpServers") && !object(mcp.mcpServers)) throw new Error(`${MCP_FILE} mcpServers must be an object`);
|
|
215
|
+
const entry = mcp.mcpServers?.[PLUGIN_KEY];
|
|
216
|
+
if (entry !== undefined && (!object(entry) || Object.keys(entry).sort().join(",") !== "args,command,env" || entry.command !== "bun" || !Array.isArray(entry.args) || entry.args.length !== 2 || entry.args[0] !== "run" || typeof entry.args[1] !== "string" || !isAbsolute(entry.args[1]) || !object(entry.env) || Object.keys(entry.env).sort().join(",") !== "AGENTHUB_PEER_ID,AGENTHUB_PROJECT_DIR,AGENTHUB_STATE_DIR" || entry.env.AGENTHUB_PEER_ID !== peer || entry.env.AGENTHUB_PROJECT_DIR !== root || entry.env.AGENTHUB_STATE_DIR !== options.stateDir)) {
|
|
217
|
+
throw new Error(`${MCP_FILE} agent-hub is a foreign MCP entry; it was not changed`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const hooks = current[HOOKS_FILE];
|
|
221
|
+
const oldLaunch = hooks ? managedHookLaunch(hooks, options, peer) : undefined;
|
|
222
|
+
if (hooks && !oldLaunch) throw new Error(`${HOOKS_FILE} agent-hub is a foreign hooks entry; it was not changed`);
|
|
223
|
+
|
|
224
|
+
const wanted = desired(options, root, peer);
|
|
225
|
+
const docs = nextDocuments(current, wanted);
|
|
226
|
+
const targets = FILES.map(relative => {
|
|
227
|
+
const old = snapshots[relative];
|
|
228
|
+
const next = Buffer.from(`${JSON.stringify(docs[relative], null, 2)}\n`);
|
|
229
|
+
return { path: join(root, relative), relative, ...(old ? { current: old.bytes, stat: old.stat } : {}), next, changed: !old || !isDeepStrictEqual(current[relative], docs[relative]) };
|
|
230
|
+
});
|
|
231
|
+
const git = hubGitSync(["ls-files", "--", ...FILES], { cwd: root, encoding: "utf8", timeout: 3000 });
|
|
232
|
+
const tracked = git.status === 0 && !!git.stdout.trim();
|
|
233
|
+
const changed = targets.some(target => target.changed);
|
|
234
|
+
return { file: pluginDir, tracked, changed, action: changed ? "create or update the managed agent-hub plugin" : "keep the managed agent-hub plugin", root, pluginDir, canonicalRoot, ...(canonicalPluginDir ? { canonicalPluginDir } : {}), ...(directories ? { directories } : {}), targets };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Preview the native plugin's three project-scoped files without writing or exposing foreign contents. */
|
|
238
|
+
export function agyMcpPlan(options: AgyMcpOptions): { file: string; tracked: boolean; changed: boolean; action: string } {
|
|
239
|
+
const { file, tracked, changed, action } = internalPlan(options);
|
|
240
|
+
return { file, tracked, changed, action };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function identityMatches(path: string, before?: Stats): boolean {
|
|
244
|
+
const now = lstatSync(path, { throwIfNoEntry: false });
|
|
245
|
+
return before ? !!now && now.dev === before.dev && now.ino === before.ino : !now;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function atomicWrite(path: string, bytes: Buffer, expected: Stats | undefined, plan: Plan): { dev: number; ino: number } {
|
|
249
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
250
|
+
let fd: number | undefined;
|
|
251
|
+
let tempIdentity: { dev: number; ino: number } | undefined;
|
|
252
|
+
try {
|
|
253
|
+
verifyPluginDirectory(plan, path);
|
|
254
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
255
|
+
const tempStat = fstatSync(fd);
|
|
256
|
+
tempIdentity = { dev: tempStat.dev, ino: tempStat.ino };
|
|
257
|
+
writeFileSync(fd, bytes);
|
|
258
|
+
closeSync(fd); fd = undefined;
|
|
259
|
+
verifyPluginDirectory(plan, path);
|
|
260
|
+
if (!identityMatches(path, expected)) throw new Error(`${path} changed while the managed plugin was being written`);
|
|
261
|
+
renameSync(temporary, path);
|
|
262
|
+
const stat = lstatSync(path);
|
|
263
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error(`${path} changed while the managed plugin was written`);
|
|
264
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
265
|
+
} finally {
|
|
266
|
+
if (fd !== undefined) closeSync(fd);
|
|
267
|
+
try {
|
|
268
|
+
const current = lstatSync(temporary, { throwIfNoEntry: false });
|
|
269
|
+
if (current && tempIdentity && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino) unlinkSync(temporary);
|
|
270
|
+
} catch { /* A temp path that no longer names our file is not ours to remove. */ }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function rollback(plan: Plan, written: { target: Target; own: { dev: number; ino: number } }[], createdDirs: { path: string; dev: number; ino: number }[]): void {
|
|
275
|
+
for (const { target, own } of written.reverse()) {
|
|
276
|
+
try {
|
|
277
|
+
const now = lstatSync(target.path, { throwIfNoEntry: false });
|
|
278
|
+
if (!now || now.dev !== own.dev || now.ino !== own.ino) continue;
|
|
279
|
+
if (target.current) atomicWrite(target.path, target.current, now, plan);
|
|
280
|
+
else unlinkSync(target.path);
|
|
281
|
+
} catch { /* Never replace a path whose ownership changed during rollback. */ }
|
|
282
|
+
}
|
|
283
|
+
for (const { path, dev, ino } of createdDirs.reverse()) {
|
|
284
|
+
try { const now = lstatSync(path); if (now.isDirectory() && !now.isSymbolicLink() && now.dev === dev && now.ino === ino) rmdirSync(path); }
|
|
285
|
+
catch { /* Keep a directory that is no longer empty or no longer ours. */ }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function ensureAgyMcp(options: AgyMcpOptions): { changed: boolean; tracked: boolean } {
|
|
290
|
+
const plan = internalPlan(options);
|
|
291
|
+
if (!plan.changed) return { changed: false, tracked: plan.tracked };
|
|
292
|
+
const createdDirs: { path: string; dev: number; ino: number }[] = [];
|
|
293
|
+
const written: { target: Target; own: { dev: number; ino: number } }[] = [];
|
|
294
|
+
try {
|
|
295
|
+
for (const relative of [".agents", ".agents/plugins", PLUGIN_DIR]) {
|
|
296
|
+
const path = join(plan.root, relative);
|
|
297
|
+
const prior = safeDirectory(path);
|
|
298
|
+
if (prior) continue;
|
|
299
|
+
mkdirSync(path, { mode: 0o700 });
|
|
300
|
+
const stat = safeDirectory(path)!;
|
|
301
|
+
createdDirs.push({ path, dev: stat.dev, ino: stat.ino });
|
|
302
|
+
}
|
|
303
|
+
plan.directories = pluginDirectories(plan.root);
|
|
304
|
+
plan.canonicalPluginDir = realPath(plan.pluginDir);
|
|
305
|
+
if (plan.canonicalRoot !== realPath(plan.root) || plan.canonicalPluginDir !== join(plan.canonicalRoot, PLUGIN_DIR)) throw new Error("managed agy plugin path changed; inspect it before writing");
|
|
306
|
+
for (const target of plan.targets) {
|
|
307
|
+
if (!target.changed) continue;
|
|
308
|
+
if (!identityMatches(target.path, target.stat)) throw new Error(`${target.path} changed while the managed plugin was being prepared`);
|
|
309
|
+
const own = atomicWrite(target.path, target.next, target.stat, plan);
|
|
310
|
+
written.push({ target, own });
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
rollback(plan, written, createdDirs);
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
return { changed: true, tracked: plan.tracked };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function agyMcpReady(options: AgyMcpOptions): boolean {
|
|
320
|
+
return !internalPlan(options).changed;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function cleanupAgyMcp(options: AgyMcpOptions): void {
|
|
324
|
+
const plan = internalPlan(options);
|
|
325
|
+
const present = plan.targets.filter(target => target.current !== undefined);
|
|
326
|
+
if (!present.length) return;
|
|
327
|
+
if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
|
|
328
|
+
const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
|
|
329
|
+
const launch = managedHookLaunch(currentHooks, options, options.peer ?? "agy");
|
|
330
|
+
if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
|
|
331
|
+
// A stale cleanup never removes files a newer config update has replaced.
|
|
332
|
+
if (plan.changed) return;
|
|
333
|
+
const deleted: { path: string; bytes: Buffer; own?: { dev: number; ino: number } }[] = [];
|
|
334
|
+
try {
|
|
335
|
+
for (const target of plan.targets) {
|
|
336
|
+
const stat = lstatSync(target.path);
|
|
337
|
+
if (!target.stat || stat.dev !== target.stat.dev || stat.ino !== target.stat.ino) throw new Error(`${target.path} changed before agy plugin cleanup`);
|
|
338
|
+
}
|
|
339
|
+
for (const target of plan.targets) {
|
|
340
|
+
if (!identityMatches(target.path, target.stat)) throw new Error(`${target.path} changed before agy plugin cleanup`);
|
|
341
|
+
if (target.relative === MCP_FILE) {
|
|
342
|
+
const currentMcp = parseJson(target.current!, MCP_FILE);
|
|
343
|
+
const servers = { ...currentMcp.mcpServers };
|
|
344
|
+
delete servers[PLUGIN_KEY];
|
|
345
|
+
const nextMcp = { ...currentMcp, mcpServers: servers };
|
|
346
|
+
const retain = Object.keys(servers).length > 0 || Object.keys(currentMcp).some(key => key !== "mcpServers");
|
|
347
|
+
if (retain) {
|
|
348
|
+
const own = atomicWrite(target.path, Buffer.from(`${JSON.stringify(nextMcp, null, 2)}\n`), target.stat, plan);
|
|
349
|
+
deleted.push({ path: target.path, bytes: target.current!, own });
|
|
350
|
+
} else {
|
|
351
|
+
unlinkSync(target.path);
|
|
352
|
+
deleted.push({ path: target.path, bytes: target.current! });
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
unlinkSync(target.path);
|
|
356
|
+
deleted.push({ path: target.path, bytes: target.current! });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
for (const item of deleted.reverse()) {
|
|
361
|
+
try {
|
|
362
|
+
const current = lstatSync(item.path, { throwIfNoEntry: false });
|
|
363
|
+
if (item.own) {
|
|
364
|
+
if (current?.dev === item.own.dev && current.ino === item.own.ino) atomicWrite(item.path, item.bytes, current, plan);
|
|
365
|
+
} else if (!current) atomicWrite(item.path, item.bytes, undefined, plan);
|
|
366
|
+
} catch { /* Restore only a path still owned by this cleanup or a path that remains missing. */ }
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface ConsoleState {
|
|
|
22
22
|
settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
|
|
23
23
|
settingsEdit?: SettingsEdit; settingsSequence?: number;
|
|
24
24
|
pendingRestart?: { key: string; reason: string }[];
|
|
25
|
+
retainedConfig?: { key: string; reason: string }[];
|
|
25
26
|
features?: readonly string[];
|
|
26
27
|
/** Older embedding callers omit choices and retain the original STAI/off toggle. */
|
|
27
28
|
petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
|
|
@@ -893,6 +894,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
893
894
|
if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
|
|
894
895
|
}
|
|
895
896
|
if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
|
|
897
|
+
if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
|
|
896
898
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
897
899
|
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
898
900
|
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
@@ -947,6 +949,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
947
949
|
const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
|
|
948
950
|
lines.push(...wrap(info, columns).slice(0, 2).map(text => [span(text, s.settingsProblem ? "failure" : "info")]));
|
|
949
951
|
if (s.pendingRestart?.length) lines.push(fitLine([span(`pending restart: ${s.pendingRestart.map(row => `${row.key}: ${row.reason}`).join("; ")}`, "attention")], columns));
|
|
952
|
+
if (s.retainedConfig?.length) {
|
|
953
|
+
lines.push(fitLine([span(`config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention")], columns));
|
|
954
|
+
for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
|
|
955
|
+
}
|
|
950
956
|
}
|
|
951
957
|
if (s.panel === 3) {
|
|
952
958
|
const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
|
package/src/cli/console.ts
CHANGED
|
@@ -196,6 +196,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
196
196
|
captureConsoleSelection(state);
|
|
197
197
|
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
198
198
|
if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
|
|
199
|
+
if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
|
|
199
200
|
if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
|
|
200
201
|
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
|
|
201
202
|
if (status.status && status.ok !== false) {
|
|
@@ -216,6 +217,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
216
217
|
state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
|
|
217
218
|
state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
|
|
218
219
|
state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
|
|
220
|
+
state.retainedConfig = Array.isArray(settings.retainedConfig) ? settings.retainedConfig : [];
|
|
219
221
|
} else state.settingsProblem = String(settings.error ?? "settings are unavailable");
|
|
220
222
|
}
|
|
221
223
|
reconcileDetail();
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
// Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
|
|
3
4
|
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
4
5
|
// A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
|
|
@@ -57,7 +58,19 @@ export function scopedWriteBlocked(phase: string, permission: unknown, tool: unk
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
60
|
-
export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
|
|
61
|
+
export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000, nativeEvent?: string): Promise<string | undefined> {
|
|
62
|
+
if (nativeEvent !== undefined) {
|
|
63
|
+
if (Buffer.byteLength(stdin, "utf8") > 1024 * 1024) return undefined;
|
|
64
|
+
const port = definitionOf(peer)?.hooks?.factsHook;
|
|
65
|
+
const payload: unknown = JSON.parse(stdin);
|
|
66
|
+
if (!port?.decode(nativeEvent, payload)) return undefined;
|
|
67
|
+
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
68
|
+
try {
|
|
69
|
+
const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
|
|
70
|
+
const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
|
|
71
|
+
return output ? JSON.stringify(output) : undefined;
|
|
72
|
+
} finally { hub.close(); }
|
|
73
|
+
}
|
|
61
74
|
const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
|
|
62
75
|
const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
63
76
|
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
@@ -88,7 +101,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
88
101
|
if (import.meta.main) {
|
|
89
102
|
try {
|
|
90
103
|
const stateDir = process.env.AGENTHUB_STATE_DIR;
|
|
91
|
-
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude") : undefined;
|
|
104
|
+
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude", 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
|
|
92
105
|
if (out) console.log(out);
|
|
93
106
|
} catch {
|
|
94
107
|
// a hook that fails must not get in the way of the tool call
|
package/src/cli/init.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, w
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { hubGitSync } from "../hub/git.ts";
|
|
7
7
|
import { KIMI_MCP_FILE } from "./kimi-mcp.ts";
|
|
8
|
+
import { AGY_PLUGIN_FILES } from "./agy-mcp.ts";
|
|
8
9
|
|
|
9
10
|
const TEMPLATES = join(import.meta.dir, "..", "..", "templates");
|
|
10
11
|
const BEGIN = "<!-- AGENT_HUB:BEGIN (managed by `ahub init`, edits inside are overwritten) -->";
|
|
@@ -96,6 +97,12 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
|
|
|
96
97
|
if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
97
98
|
}
|
|
98
99
|
if (existsSync(join(cwd, KIMI_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", KIMI_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(KIMI_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${KIMI_MCP_FILE}\n`;
|
|
100
|
+
const agyPlugin = ".agents/plugins/agent-hub/";
|
|
101
|
+
if (existsSync(join(cwd, agyPlugin))) {
|
|
102
|
+
const tracked = new Set(hubGitSync(["ls-files", "--", agyPlugin], { cwd, encoding: "utf8", timeout: 3000 }).stdout.trim().split("\n").filter(Boolean));
|
|
103
|
+
const entries = tracked.size ? AGY_PLUGIN_FILES.filter(file => !tracked.has(file)) : [agyPlugin];
|
|
104
|
+
for (const entry of entries) if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
105
|
+
}
|
|
99
106
|
if (existsSync(join(cwd, JCODE_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(JCODE_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${JCODE_MCP_FILE}\n`;
|
|
100
107
|
write(ignore, lines, "ignore runtime state and machine-local configuration");
|
|
101
108
|
return changed;
|
package/src/cli/launch.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type { InstancesConfig } from "../hub/instances.ts";
|
|
|
12
12
|
import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
|
|
13
13
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
14
14
|
|
|
15
|
+
export { retainedLaunchWarning } from "../adapters/definitions/native-cli-retained.ts";
|
|
16
|
+
|
|
15
17
|
/** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
|
|
16
18
|
export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[]; hubStarted?: true } {
|
|
17
19
|
if (!definitionOf(command)) return { args: [...raw] };
|
|
@@ -52,7 +54,7 @@ export function nativeLaunchEnv(tool: string, source: NodeJS.ProcessEnv, instanc
|
|
|
52
54
|
delete env.AGENTHUB_UNATTENDED;
|
|
53
55
|
// Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
|
|
54
56
|
const definition = definitionOf(tool, instances);
|
|
55
|
-
if (definition?.identity.mcp === "channel-plugin") env.AGENTHUB_CHANNEL = "1";
|
|
57
|
+
if (definition?.identity.mcp === "channel-plugin" && !definition.attach?.pullOnlyForms?.includes("tui")) env.AGENTHUB_CHANNEL = "1";
|
|
56
58
|
if (definition?.attach?.pullOnlyForms?.includes("tui")) delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
|
|
57
59
|
return env;
|
|
58
60
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -255,8 +255,8 @@ function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi", e
|
|
|
255
255
|
process.exit(res.status ?? 1);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, executionCwd = cwd): never {
|
|
259
|
-
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(
|
|
258
|
+
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, peer: string, executionCwd = cwd): never {
|
|
259
|
+
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(peer, extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
260
260
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
261
261
|
delete env.AGENTHUB_UNATTENDED;
|
|
262
262
|
const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env });
|
|
@@ -854,11 +854,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
854
854
|
if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
|
|
855
855
|
const hub = await nativeConnect(); hub.close();
|
|
856
856
|
}
|
|
857
|
-
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
|
|
857
|
+
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, person: caller.role === "console", definition: entry.definition,
|
|
858
858
|
...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
|
|
859
859
|
projectConfig, connect: nativeConnect, fail,
|
|
860
860
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
861
|
-
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
|
|
861
|
+
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
862
862
|
helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
|
|
863
863
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
864
864
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
@@ -906,6 +906,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
906
906
|
hub.close();
|
|
907
907
|
if (!res.ok) fail(res.error);
|
|
908
908
|
if (res.recorded) return console.log("recorded only ([FYI]); no peer was interrupted");
|
|
909
|
+
if (res.dropped === "hop") return console.log(res.notice);
|
|
909
910
|
console.log(paint([{ text: res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered" }], false));
|
|
910
911
|
if (typeof res.notice === "string" && res.notice) console.log(paint([{ text: res.notice }], false));
|
|
911
912
|
},
|
package/src/cli/output.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
|
|
|
9
9
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
10
10
|
export interface StatusOutput {
|
|
11
11
|
pendingRestart?: readonly { key: string; reason: string }[];
|
|
12
|
+
retainedConfig?: readonly { key: string; reason: string }[];
|
|
12
13
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
13
14
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
14
15
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
@@ -191,6 +192,10 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
191
192
|
const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
|
|
192
193
|
if (data.cwd) out.push(...lines(data.cwd, columns));
|
|
193
194
|
for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
195
|
+
if (data.retainedConfig?.length) {
|
|
196
|
+
out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
|
|
197
|
+
for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
198
|
+
}
|
|
194
199
|
for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
|
|
195
200
|
for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
|
|
196
201
|
if (data.memoryProfile) {
|
package/src/cli/preview.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
4
4
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
5
5
|
import { instanceLaunchArguments, buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
|
|
6
6
|
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
7
|
-
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
7
|
+
import { jcodeCommand, jcodeTuiCommand } from "../adapters/jcode.ts";
|
|
8
8
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
9
9
|
import { relayModelIds } from "../models/relay.ts";
|
|
10
10
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
@@ -24,7 +24,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
24
24
|
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
|
|
25
25
|
baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
|
|
26
26
|
helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
27
|
-
startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
27
|
+
startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
28
28
|
});
|
|
29
29
|
if (!selected.instance) return result;
|
|
30
30
|
return { ...result, instance: selected.instance,
|
package/src/hub/bus.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
|
7
7
|
|
|
8
8
|
export type { DeliveryReceipt } from "./peers.ts";
|
|
9
9
|
|
|
10
|
+
export interface PublishResult {
|
|
11
|
+
targets: PeerId[];
|
|
12
|
+
dropped?: "hop" | "fyi";
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export type BusEvent =
|
|
11
16
|
| { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
|
|
12
17
|
| { t: "overflow"; env: Envelope; peer: PeerId }
|
|
@@ -294,6 +299,7 @@ export class Bus {
|
|
|
294
299
|
// Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
|
|
295
300
|
// leaves the hold and the original pending head intact; a crash after it resumes from the journal.
|
|
296
301
|
this.flushDeferredHub();
|
|
302
|
+
this.deferHub = false;
|
|
297
303
|
this.recoveryHeld = false;
|
|
298
304
|
this.onQueues?.();
|
|
299
305
|
for (const id of this.peers.keys()) void this.drain(id);
|
|
@@ -698,8 +704,13 @@ export class Bus {
|
|
|
698
704
|
|
|
699
705
|
/** Returns the peers the envelope was accepted for, including durable deferred notices. */
|
|
700
706
|
publish(env: Envelope): PeerId[] {
|
|
707
|
+
return this.publishResult(env).targets;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Publication outcome distinguishes a policy drop from an empty audience. */
|
|
711
|
+
publishResult(env: Envelope): PublishResult {
|
|
701
712
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
702
|
-
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
|
|
713
|
+
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
|
|
703
714
|
if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
|
|
704
715
|
if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
|
|
705
716
|
const targets = this.audience(env);
|
|
@@ -707,21 +718,21 @@ export class Bus {
|
|
|
707
718
|
this.deferredHub.set(copy.id, copy);
|
|
708
719
|
try { this.persist(); }
|
|
709
720
|
catch (error) { this.deferredHub.delete(copy.id); throw error; }
|
|
710
|
-
return targets;
|
|
721
|
+
return { targets };
|
|
711
722
|
}
|
|
712
723
|
return this.publishNow(env);
|
|
713
724
|
}
|
|
714
725
|
|
|
715
|
-
private publishNow(env: Envelope):
|
|
726
|
+
private publishNow(env: Envelope): PublishResult {
|
|
716
727
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
717
|
-
if (this.seen.has(env.id)) return [];
|
|
728
|
+
if (this.seen.has(env.id)) return { targets: [] };
|
|
718
729
|
this.seen.set(env.id, env);
|
|
719
730
|
if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
720
731
|
|
|
721
732
|
// fyi is for the record (console now, task board from M4), never worth an agent's turn.
|
|
722
733
|
const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
|
|
723
734
|
this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
|
|
724
|
-
if (dropped) { this.persist(); return []; }
|
|
735
|
+
if (dropped) { this.persist(); return { targets: [], dropped }; }
|
|
725
736
|
|
|
726
737
|
const hushed = this.hushed(env);
|
|
727
738
|
if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
|
|
@@ -756,7 +767,7 @@ export class Bus {
|
|
|
756
767
|
}
|
|
757
768
|
const offRoster = targets.filter(id => !this.isRostered(id));
|
|
758
769
|
if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
|
|
759
|
-
return targets;
|
|
770
|
+
return { targets };
|
|
760
771
|
}
|
|
761
772
|
|
|
762
773
|
isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
|