@staix/agent-hub 0.12.33 → 0.12.34

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 (100) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +5 -3
  5. package/docs/agent-notes/daemon.md +3 -3
  6. package/docs/agent-notes/tasks.md +2 -1
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/operations.md +20 -10
  9. package/docs/quickstart.md +4 -2
  10. package/docs/security.md +4 -1
  11. package/docs/smoke.md +1 -1
  12. package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
  13. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  14. package/docs/verified.json +29 -18
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +22882 -16496
  18. package/src/adapters/codex-appserver.ts +81 -11
  19. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  20. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  21. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  22. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  23. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  24. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  25. package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
  26. package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
  27. package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
  28. package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
  29. package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
  30. package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
  31. package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
  32. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  33. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  34. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  35. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  36. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  37. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  38. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  39. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  40. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  41. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  42. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  43. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  44. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  45. package/src/adapters/definitions/native-config-policy.ts +35 -0
  46. package/src/adapters/definitions/native-context.ts +35 -0
  47. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  48. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  49. package/src/adapters/definitions/native-display.ts +18 -0
  50. package/src/adapters/definitions/native-factories.ts +48 -0
  51. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  52. package/src/adapters/definitions/native-quota.ts +29 -0
  53. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  54. package/src/adapters/definitions/native-recovery.ts +88 -0
  55. package/src/adapters/definitions/qwen.ts +8 -4
  56. package/src/adapters/registry-types.ts +97 -10
  57. package/src/adapters/registry.ts +103 -3
  58. package/src/cli/console-state.ts +5 -5
  59. package/src/cli/identity.ts +6 -5
  60. package/src/cli/jcode-mcp.ts +2 -58
  61. package/src/cli/launch.ts +14 -100
  62. package/src/cli/main.ts +35 -272
  63. package/src/cli/output.ts +3 -2
  64. package/src/cli/preview.ts +14 -125
  65. package/src/cli/recovery-guidance.ts +17 -13
  66. package/src/cli/setup.ts +2 -80
  67. package/src/cli/shell-quote.ts +3 -0
  68. package/src/cli/terminal-recovery.ts +17 -10
  69. package/src/cli/upgrade-interactive.ts +4 -2
  70. package/src/cli/upgrade-restore-state.ts +2 -1
  71. package/src/cli/upgrade-runtime.ts +92 -141
  72. package/src/cli/upgrade.ts +8 -7
  73. package/src/hub/budget.ts +6 -30
  74. package/src/hub/conductor.ts +8 -4
  75. package/src/hub/config-apply.ts +9 -13
  76. package/src/hub/config-reload.ts +7 -2
  77. package/src/hub/context-window.ts +3 -27
  78. package/src/hub/crash.ts +2 -35
  79. package/src/hub/daemon.ts +226 -232
  80. package/src/hub/execution-budget.ts +2 -1
  81. package/src/hub/hub-tools.ts +3 -3
  82. package/src/hub/peer-catalog.ts +4 -13
  83. package/src/hub/permission-mode.ts +9 -12
  84. package/src/hub/recovery-continuity.ts +6 -5
  85. package/src/hub/report.ts +9 -8
  86. package/src/hub/roster.ts +6 -7
  87. package/src/hub/routing.ts +21 -10
  88. package/src/hub/settings.ts +6 -5
  89. package/src/hub/settled-roles.ts +1 -1
  90. package/src/hub/start-mode.ts +8 -6
  91. package/src/hub/tasks.ts +13 -10
  92. package/src/hub/tooling.ts +4 -60
  93. package/src/hub/ui.ts +3 -1
  94. package/src/memory/recall.ts +4 -5
  95. package/src/models/route/signals.ts +3 -1
  96. package/src/pi/launch.ts +2 -1
  97. package/src/ui/index.html +3 -2
  98. package/src/ui/office-model.ts +3 -1
  99. package/src/ui/peer-palette.ts +16 -6
  100. package/templates/routing.toml +2 -2
package/src/hub/budget.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { PeerId } from "./envelope.ts";
3
4
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -70,8 +71,6 @@ const WAIT_NOTE = "; keeps its work:";
70
71
  const WAITED_NOTE = "; waited, then the reset moved to";
71
72
  const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
72
73
  const FALLBACK_WINDOW_MINS = 300;
73
- /** Peers the coordinator never pauses: the local worker has no quota. */
74
- const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
75
74
 
76
75
  /**
77
76
  * Budget relay. Readings come from the adapters (Codex rate limits, Claude's status line, Kimi tokens) or from
@@ -156,7 +155,10 @@ export class Budget {
156
155
  /** `hard`: the peer cannot run another turn (its provider refused one), so there is nobody to ask for a checkpoint. */
157
156
  /** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
158
157
  report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
159
- if (EXEMPT.has(peer) || this.closed) return;
158
+ // Execution-budget instrumentation is independent of native quota accounting.
159
+ // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
160
+ const adapter = definitionOf(peer);
161
+ if (peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
160
162
  // An override ignores soft readings, not a provider that has actually refused another turn.
161
163
  if (hard && this.ignoreUntil.delete(peer)) {
162
164
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
@@ -356,30 +358,4 @@ export class Budget {
356
358
  }
357
359
  }
358
360
 
359
- /** Codex `rateLimits` snapshot (account/rateLimits/read, account/rateLimits/updated) -> windows. */
360
- export function codexWindows(rateLimits: any): UsageWindow[] {
361
- const out: UsageWindow[] = [];
362
- // The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
363
- // readings are keyed by window id, so two windows must never both come out as "5h".
364
- for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
365
- if (!w || typeof w.usedPercent !== "number") continue;
366
- const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
367
- const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
368
- if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
369
- out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
370
- }
371
- if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
372
- return out;
373
- }
374
-
375
- /** Claude Code status line `rate_limits` -> windows. */
376
- export function claudeWindows(rateLimits: any): UsageWindow[] {
377
- const out: UsageWindow[] = [];
378
- for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
379
- const w = rateLimits?.[key];
380
- if (!w || typeof w.used_percentage !== "number") continue;
381
- const resets = Number(w.resets_at);
382
- out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
383
- }
384
- return out;
385
- }
361
+ export { codexWindows, claudeWindows } from "../adapters/definitions/native-quota.ts";
@@ -1,10 +1,11 @@
1
+ import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { Task } from "./board.ts";
3
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
5
  import { HUB, USER } from "./envelope.ts";
5
6
  import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
6
7
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
- import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
8
+ import type { StartMode } from "./start-mode.ts";
8
9
  import type { HubEvent } from "./events.ts";
9
10
  import type { SupervisionFeed } from "./supervision.ts";
10
11
  import type { Budget } from "./budget.ts";
@@ -103,6 +104,8 @@ export class ConductorHolds {
103
104
  close(): void { this.db.close(); }
104
105
  }
105
106
 
107
+ type StartablePeer = typeof SUPPORTED_PEERS[number];
108
+
106
109
  export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
107
110
  /**
108
111
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
@@ -111,10 +114,11 @@ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer:
111
114
  export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
112
115
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
113
116
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
114
- if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
115
- const own = startMode(peer);
117
+ if (!(typeof peer === "string" && definitionOf(peer)?.start.conductorStartable)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
118
+ const selected = peer as StartablePeer;
119
+ const own = startMode(selected);
116
120
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
117
- return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
121
+ return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
118
122
  }
119
123
 
120
124
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -1,6 +1,6 @@
1
1
  import type { HubConfig } from "./daemon.ts";
2
2
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
3
- import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
3
+ import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
4
  import { CLASSES } from "./board.ts";
5
5
  import { permissionDefaults } from "./permission-mode.ts";
6
6
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -103,7 +103,9 @@ const block = (schema: Record<string, Check>): Check => {
103
103
  };
104
104
  const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
105
105
  const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
106
+ const nativeValidators = { bool, text, strings, argv, number, words, block };
106
107
  const schema: Record<string, Check> = {
108
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
107
109
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
108
110
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
109
111
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
@@ -119,11 +121,9 @@ const schema: Record<string, Check> = {
119
121
  context: block({ gate: number(0, 1), stale_min: timerMinutes }),
120
122
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
121
123
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
122
- pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
123
124
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
124
125
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
125
- local: block({ deny: strings, bash_network: (v, k) => { if (v !== "direct") bool(v, k); }, network_allow: strings, max_steps: number(1, 1_000_000, true), read_allow: strings }),
126
- approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
126
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
127
127
  task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
128
128
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
129
129
  if (value === undefined) continue;
@@ -132,7 +132,7 @@ const schema: Record<string, Check> = {
132
132
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
133
133
  } },
134
134
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
135
- peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), ["pi", "codex", "kimi"])(v, k); peerStartConfig(v); },
135
+ peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
136
136
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
137
137
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
138
138
  review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
@@ -145,23 +145,19 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
145
145
  validateRawConfig(raw);
146
146
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
147
147
  for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
148
- if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
148
+ for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
149
149
  }
150
150
 
151
151
  const restart: Record<string, string> = {
152
- "pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
153
- "pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
154
- "routing.pi": "Pi relay context and model publication are bound to the running relay",
152
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
155
153
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
156
154
  omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
157
155
  checks: "Bounded runner and completion-check contracts are constructed together",
158
156
  pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
159
- "local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
160
- "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
161
157
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
162
158
  text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
163
159
  };
164
- const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
160
+ const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
165
161
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
166
162
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
167
163
  export function configApply(key: string): ApplyRule {
@@ -182,7 +178,7 @@ export function configApply(key: string): ApplyRule {
182
178
  else return fail(key, "unknown configuration key");
183
179
  }
184
180
  }
185
- if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
181
+ if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
186
182
  return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
187
183
  }
188
184
  if (nextMemory.has(key)) return { applies: "next use" };
@@ -1,3 +1,4 @@
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";
@@ -66,15 +67,19 @@ function validateRoutingRaw(value: unknown): void {
66
67
  const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
67
68
  }
68
69
  };
70
+ const nativeValidators = { bool, text, strings, argv: strings,
71
+ number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
72
+ words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
73
+ block: (fields: Record<string, (v: unknown, k: string) => void>) => (v: unknown, k: string) => closed(v, k, fields),
74
+ };
69
75
  closed(value, "routing", {
70
- local: (v, k) => closed(v, k, { route: text, fixed_model: text }),
76
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
71
77
  targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
72
78
  // parseRouting delegates these tables to the existing parseHubRoutes validator.
73
79
  hub_routes: (v, k) => { table(v, k); },
74
80
  stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
75
81
  signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
76
82
  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
83
  classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
79
84
  if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
80
85
  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"]) });
@@ -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 {
package/src/hub/crash.ts CHANGED
@@ -61,45 +61,12 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
61
61
  const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
62
62
  const model = str(launch.model);
63
63
  const adapter = definitionOf(peer);
64
- if (adapter?.transport === "acp" && adapter.start.create) {
64
+ if (adapter?.session.resumeById) {
65
65
  const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
66
66
  if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ahub ${peer}` };
67
67
  return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
68
68
  }
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
- }
69
+ return { peer, ...(adapter?.session.crashPlan?.(meta) ?? { how: `${peer}: reconnects by itself if its client is still running` }) };
103
70
  });
104
71
  }
105
72