@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.
- package/CHANGELOG.md +9 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/daemon.md +3 -3
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +20 -10
- package/docs/quickstart.md +4 -2
- package/docs/security.md +4 -1
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +92 -141
- package/src/cli/upgrade.ts +8 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +226 -232
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +13 -10
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- 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
|
-
|
|
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
|
-
|
|
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";
|
package/src/hub/conductor.ts
CHANGED
|
@@ -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
|
|
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 (!
|
|
115
|
-
const
|
|
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;
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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"]) }),
|
|
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
|
-
|
|
148
|
+
for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
const restart: Record<string, string> = {
|
|
152
|
-
|
|
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" && !
|
|
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" };
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -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
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
|