@staix/agent-hub 0.12.33 → 0.12.35

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 (126) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +9 -3
  4. package/docs/agent-notes/budget.md +7 -3
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +7 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +2 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +80 -16
  12. package/docs/quickstart.md +10 -2
  13. package/docs/sdlc.md +59 -34
  14. package/docs/security.md +7 -4
  15. package/docs/smoke.md +1 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
  17. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  18. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  19. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  20. package/docs/verified.json +35 -19
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +23567 -16606
  24. package/src/adapters/acp.ts +3 -1
  25. package/src/adapters/claude-channel.ts +59 -29
  26. package/src/adapters/codex-appserver.ts +82 -12
  27. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  28. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  29. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  30. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  31. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  32. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  33. package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
  34. package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
  35. package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
  36. package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
  37. package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
  39. package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
  40. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  41. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  42. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  43. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  44. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  45. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  46. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  47. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  48. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  49. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  50. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  51. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  52. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  53. package/src/adapters/definitions/native-config-policy.ts +35 -0
  54. package/src/adapters/definitions/native-context.ts +35 -0
  55. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  56. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  57. package/src/adapters/definitions/native-display.ts +18 -0
  58. package/src/adapters/definitions/native-factories.ts +48 -0
  59. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  60. package/src/adapters/definitions/native-quota.ts +29 -0
  61. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  62. package/src/adapters/definitions/native-recovery.ts +88 -0
  63. package/src/adapters/definitions/qwen-native.ts +2 -2
  64. package/src/adapters/definitions/qwen.ts +8 -4
  65. package/src/adapters/local-worker.ts +3 -2
  66. package/src/adapters/pi.ts +3 -1
  67. package/src/adapters/registry-types.ts +100 -10
  68. package/src/adapters/registry.ts +127 -11
  69. package/src/cli/console-state.ts +5 -5
  70. package/src/cli/identity.ts +8 -7
  71. package/src/cli/jcode-mcp.ts +2 -58
  72. package/src/cli/launch.ts +41 -102
  73. package/src/cli/main.ts +126 -290
  74. package/src/cli/output.ts +3 -2
  75. package/src/cli/preview.ts +26 -126
  76. package/src/cli/recovery-guidance.ts +43 -22
  77. package/src/cli/setup.ts +2 -80
  78. package/src/cli/shell-quote.ts +3 -0
  79. package/src/cli/terminal-recovery.ts +81 -22
  80. package/src/cli/upgrade-interactive.ts +4 -2
  81. package/src/cli/upgrade-restore-state.ts +17 -7
  82. package/src/cli/upgrade-runtime.ts +126 -147
  83. package/src/cli/upgrade.ts +44 -27
  84. package/src/hub/budget.ts +59 -31
  85. package/src/hub/bus.ts +1 -1
  86. package/src/hub/conductor.ts +19 -9
  87. package/src/hub/config-apply.ts +22 -15
  88. package/src/hub/config-reload.ts +18 -3
  89. package/src/hub/config-trust.ts +1 -1
  90. package/src/hub/context-window.ts +3 -27
  91. package/src/hub/control-client.ts +1 -1
  92. package/src/hub/crash.ts +15 -39
  93. package/src/hub/daemon.ts +506 -341
  94. package/src/hub/execution-budget.ts +3 -2
  95. package/src/hub/hub-tools.ts +19 -8
  96. package/src/hub/instance-budget.ts +73 -0
  97. package/src/hub/instance-config.ts +47 -0
  98. package/src/hub/instance-execution.ts +50 -0
  99. package/src/hub/instances.ts +100 -0
  100. package/src/hub/lifecycle-schema.ts +7 -3
  101. package/src/hub/peer-catalog.ts +4 -13
  102. package/src/hub/permission-mode.ts +14 -17
  103. package/src/hub/recovery-continuity.ts +6 -5
  104. package/src/hub/report.ts +9 -8
  105. package/src/hub/roster.ts +9 -9
  106. package/src/hub/routing.ts +94 -19
  107. package/src/hub/sdlc-queue.ts +2 -2
  108. package/src/hub/sdlc.ts +84 -38
  109. package/src/hub/settings.ts +39 -10
  110. package/src/hub/settled-roles.ts +1 -1
  111. package/src/hub/snapshots.ts +40 -9
  112. package/src/hub/start-mode.ts +13 -8
  113. package/src/hub/status-roster.ts +8 -0
  114. package/src/hub/tasks.ts +60 -27
  115. package/src/hub/text-projection.ts +7 -3
  116. package/src/hub/tooling.ts +4 -60
  117. package/src/hub/ui.ts +3 -1
  118. package/src/hub/workflow.ts +30 -52
  119. package/src/memory/recall.ts +4 -5
  120. package/src/models/route/signals.ts +3 -1
  121. package/src/pi/launch.ts +5 -3
  122. package/src/ui/index.html +4 -3
  123. package/src/ui/office-model.ts +3 -1
  124. package/src/ui/peer-palette.ts +16 -6
  125. package/templates/AGENTS.block.md +2 -1
  126. package/templates/routing.toml +2 -2
@@ -1,9 +1,11 @@
1
+ import { ADAPTERS } from "../adapters/registry.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
5
6
  import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
6
7
  import { CLASSES } from "./board.ts";
8
+ import { validateInstances } from "./instances.ts";
7
9
 
8
10
  export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
9
11
  export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
@@ -66,15 +68,19 @@ function validateRoutingRaw(value: unknown): void {
66
68
  const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
67
69
  }
68
70
  };
71
+ const nativeValidators = { bool, text, strings, argv: strings,
72
+ number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
73
+ words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
74
+ block: (fields: Record<string, (v: unknown, k: string) => void>) => (v: unknown, k: string) => closed(v, k, fields),
75
+ };
69
76
  closed(value, "routing", {
70
- local: (v, k) => closed(v, k, { route: text, fixed_model: text }),
77
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
71
78
  targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
72
79
  // parseRouting delegates these tables to the existing parseHubRoutes validator.
73
80
  hub_routes: (v, k) => { table(v, k); },
74
81
  stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
75
82
  signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
76
83
  constraints: (v, k) => closed(v, k, { pii: (x, p) => choice(x, p, ["local_only", "off"]), long_context: (x, p) => choice(x, p, ["skip_local", "off"]), budget_paused: (x, p) => choice(x, p, ["skip_peer", "off"]) }),
77
- pi: (v, k) => closed(v, k, { dgx_max_context_tokens: numeric, mlx_max_context_tokens: numeric, efficient_wait_ms: (x, p) => numeric(x, p, 0, 119999) }),
78
84
  classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
79
85
  if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
80
86
  closed(item, `${k}.${name}`, { peers, escalate_to: peers, route: text, fixed_model: text, local_allowed: bool, pi_backend: (x, p) => choice(x, p, ["dgx", "mlx"]) });
@@ -85,6 +91,15 @@ function validateRoutingRaw(value: unknown): void {
85
91
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
86
92
  export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
87
93
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
94
+ let localRaw: Record<string, unknown> = {};
95
+ try { localRaw = JSON.parse(texts["config.local.json"] ?? "{}"); }
96
+ catch { if (!fallbackRouting) throw new Error(".agenthub/config.local.json: invalid JSON object"); }
97
+ let declared;
98
+ try { declared = validateInstances(localRaw?.instances); }
99
+ catch (error) {
100
+ if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
101
+ declared = validateInstances(undefined);
102
+ }
88
103
  for (const name of CONFIG_SOURCE_FILES) {
89
104
  const text = texts[name];
90
105
  if (text === null) continue;
@@ -99,7 +114,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
99
114
  }
100
115
  if (name === "config.json" || name === "config.local.json") {
101
116
  if (!fallbackRouting) {
102
- try { validateRawConfig(raw); }
117
+ try { validateRawConfig(raw, declared); }
103
118
  catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
104
119
  }
105
120
  layers.push({ name, raw });
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
31
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection", "instances"] as const;
33
33
 
34
34
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
35
35
  export function configRepositoryPresent(cwd: string): boolean {
@@ -1,3 +1,6 @@
1
+ import { nonnegative, positive } from "../adapters/definitions/native-context.ts";
2
+ export { claudeContext, codexContext, acpContext } from "../adapters/definitions/native-context.ts";
3
+
1
4
  /** Native active-context occupancy, separate from accumulated billable usage (#185). */
2
5
  export interface ContextReading {
3
6
  source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
@@ -17,33 +20,6 @@ export interface ContextView {
17
20
  }
18
21
  export interface ContextConfig { gate: number; stale_min: number }
19
22
  export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
20
- const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
21
- const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
22
- const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
23
- const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
24
- export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
25
- const c = object(value);
26
- const current = object(c.current_usage);
27
- const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
28
- const window = positive(c.context_window_size) ? c.context_window_size : null;
29
- const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
30
- const tokens = nonnegative(sum) ? sum : null;
31
- const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
32
- return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
33
- }
34
- export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
35
- const c = object(value), last = object(c.last);
36
- const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
37
- const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
38
- return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
39
- }
40
- /** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
41
- export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
42
- const window = positive(usage.size) ? usage.size : null;
43
- const tokens = nonnegative(usage.used) ? usage.used : null;
44
- // If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
45
- return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
46
- }
47
23
  export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
48
24
  /** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
49
25
  export class ContextWindows {
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
11
+ export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14
 
package/src/hub/crash.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
2
  import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { JournalDelivery } from "./delivery-journal.ts";
@@ -56,50 +56,26 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
56
56
  * What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
57
57
  * ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
58
58
  */
59
- export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
59
+ export function crashPlan(records: SessionRecord[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
60
60
  return records.map(({ peer, meta }) => {
61
61
  const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
62
62
  const model = str(launch.model);
63
- const adapter = definitionOf(peer);
64
- if (adapter?.transport === "acp" && adapter.start.create) {
63
+ const adapter = definitionOf(peer, instances);
64
+ const extra = adapter !== undefined && definitionOf(peer) === undefined;
65
+ const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
66
+ if (adapter?.session.resumeById) {
65
67
  const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
66
- if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ahub ${peer}` };
68
+ if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
67
69
  return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
68
70
  }
69
- switch (peer) {
70
- case "claude":
71
- return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
72
- case "codex":
73
- return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
74
- case "jcode":
75
- return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
76
- case "kimi": {
77
- const sessionId = str(meta.sessionId);
78
- if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
79
- return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `kimi: session ${sessionId} can be loaded again (ACP session/load)` };
80
- }
81
- case "pi": {
82
- // `fresh`: what a new headless session keeps of the recorded one (pi.auto_start's fallback, #66).
83
- const fresh: Record<string, string> = {};
84
- for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
85
- const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
86
- // #269: a terminal Pi stays marked as one without a session file too, so nothing headless is started in its place.
87
- if (!sessionFile) return { peer, fresh, ...(str(launch.mode) === "tui" ? { tui: true } : {}), how: "pi: no session file was recorded; start it again with ahub pi" };
88
- // A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
89
- if (str(launch.mode) === "tui") return { peer, fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
90
- const args: Record<string, string> = { sessionFile };
91
- for (const k of ["mode", "backend", "model"] as const) if (str(launch[k])) args[k] = str(launch[k])!;
92
- return { peer, fresh, resume: args, how: `pi: its session file can be resumed (${sessionFile})` };
93
- }
94
- case "local": {
95
- // `model` is also recorded as the route's fallback, and a model given at start pins it: pass one or the other.
96
- const route = str(launch.route);
97
- const args: Record<string, string> = route ? { route } : model ? { model } : {};
98
- return { peer, resume: args, how: "local: starts again without its history (the worker keeps none across a hub stop)" };
99
- }
100
- default:
101
- return { peer, how: `${peer}: reconnects by itself if its client is still running` };
102
- }
71
+ const native = adapter?.session.crashPlan?.(meta);
72
+ if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };
73
+ if (!extra) return { peer, ...native };
74
+ const prefix = `${adapter.kind}:`;
75
+ let how = native.how.startsWith(prefix) ? `${peer}:${native.how.slice(prefix.length)}` : `${peer}: ${native.how}`;
76
+ // Manual launch guidance selects this instance. Recorded headless resume prose can contain an opaque id, never a command.
77
+ if (!native.resume || native.tui) how = how.replace(`ahub ${adapter.cli.command}`, command);
78
+ return { peer, ...native, how };
103
79
  });
104
80
  }
105
81