@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/adapters.md +19 -8
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +6 -3
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +3 -3
  9. package/docs/operations.md +45 -17
  10. package/docs/quickstart.md +19 -5
  11. package/docs/security.md +7 -3
  12. package/docs/smoke.md +22 -2
  13. package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
  14. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  15. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  16. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  17. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  20. package/docs/verified.json +22 -14
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1759 -437
  24. package/src/adapters/acp.ts +20 -2
  25. package/src/adapters/agy/definition.ts +61 -0
  26. package/src/adapters/agy/entry.ts +76 -0
  27. package/src/adapters/agy/hooks.ts +81 -0
  28. package/src/adapters/claude-channel.ts +1 -0
  29. package/src/adapters/codex-review-isolation.ts +30 -16
  30. package/src/adapters/definitions/builtin-metadata.ts +8 -8
  31. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  32. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  33. package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
  34. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  35. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  37. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  38. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  39. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  41. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  42. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  43. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  44. package/src/adapters/definitions/qwen.ts +1 -1
  45. package/src/adapters/definitions/reviewer-variants.ts +4 -3
  46. package/src/adapters/jcode.ts +58 -1
  47. package/src/adapters/kimi-review.ts +161 -0
  48. package/src/adapters/registry-types.ts +29 -1
  49. package/src/adapters/registry.ts +16 -7
  50. package/src/adapters/reviewer-mcp.ts +85 -0
  51. package/src/adapters/reviewer-print.ts +83 -20
  52. package/src/cli/agy-mcp.ts +370 -0
  53. package/src/cli/console-state.ts +6 -0
  54. package/src/cli/console.ts +2 -0
  55. package/src/cli/facts-hook.ts +15 -2
  56. package/src/cli/init.ts +7 -0
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +5 -4
  59. package/src/cli/output.ts +5 -0
  60. package/src/cli/preview.ts +2 -2
  61. package/src/hub/bus.ts +17 -6
  62. package/src/hub/control-client.ts +7 -1
  63. package/src/hub/daemon.ts +182 -24
  64. package/src/hub/native-hook-launch.ts +37 -0
  65. package/src/hub/peer-catalog.ts +2 -3
  66. package/src/hub/peers.ts +2 -2
  67. package/src/hub/reviewer-pool.ts +12 -6
  68. package/src/hub/routing.ts +3 -0
  69. package/src/hub/start-mode.ts +1 -1
  70. package/src/hub/tasks.ts +7 -0
  71. package/src/ui/index.html +6 -1
  72. 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
+ }
@@ -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;
@@ -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();
@@ -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(selectedInstance ?? "pi", extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
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) {
@@ -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): PeerId[] {
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; }