@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -1,17 +1,24 @@
1
1
  import type { ModelBackend, ModelRelayOptions, RelayRequest } from "../relay.ts";
2
2
  import { normalizeConversation } from "./normalize.ts";
3
- import { extractToolSignals } from "./signals.ts";
4
- import { selectStage, type StageState } from "./stage.ts";
3
+ import { extractToolSignals, turnKind } from "./signals.ts";
4
+ import { selectStage, stayOrSwitch, type StageState, type SwitchTrace, type Tier } from "./stage.ts";
5
5
  import { SessionState } from "./state.ts";
6
6
 
7
- type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "onRoute" | "allowedDGXmodels">;
8
- type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default";
9
- type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number };
7
+ type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "allowedDGXmodels" | "staySwitch"> & { dgxMaxInputTokens: number };
8
+ type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin";
9
+ /** `stateless`: the request carried no session key, so the planner saw a new session on every call. */
10
+ export type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number; stateless?: boolean } & Partial<SwitchTrace>;
10
11
  type EstimateInputTokens = (messages: RelayRequest["messages"], tools?: unknown[]) => number;
12
+ type AutoState = { stage: StageState; pin?: { tier: Tier; alias: string } };
13
+ /**
14
+ * A `hub/auto` decision (#199). `backend` is the stage backend; the relay only reorders it with its fallback, trying
15
+ * `prefer` (an enforced tool loop's pinned backend) first when that is the fallback, and reports a reorder through `moved`.
16
+ */
17
+ export interface AutoChoice { backend: ModelBackend; route: RelayRouteEvent; movable: boolean; prefer?: string; moved: (alias: string, source: "load" | "cooldown" | "pin") => void }
11
18
 
12
19
  /** Stage selection for the relay's virtual `hub/auto` model. */
13
20
  export class AutoRouteSelector {
14
- private readonly states = new SessionState<StageState>(512, 60 * 60_000);
21
+ private readonly states = new SessionState<AutoState>(512, 60 * 60_000);
15
22
 
16
23
  constructor(
17
24
  private readonly options: RelaySelectorOptions,
@@ -20,33 +27,38 @@ export class AutoRouteSelector {
20
27
  private readonly estimateInputTokens: EstimateInputTokens,
21
28
  ) {}
22
29
 
23
- async select(body: RelayRequest): Promise<ModelBackend> {
30
+ async select(body: RelayRequest): Promise<AutoChoice> {
24
31
  const started = performance.now();
25
32
  let sessionKey: string | undefined;
26
33
  try {
27
34
  const key = this.options.routeSessionKey?.(body);
28
35
  if (typeof key === "string" && key.length > 0 && key.length <= 512) sessionKey = key;
29
36
  } catch { /* host identity lookup is fail-open */ }
30
- const priorState = sessionKey ? this.states.get(sessionKey) ?? { capableHoldTurnsRemaining: 0 } : { capableHoldTurnsRemaining: 0 };
37
+ const prior = sessionKey ? this.states.get(sessionKey) : undefined;
31
38
 
32
39
  let backend = this.defaultBackend;
33
- let tier = this.aliasOf(backend);
34
40
  let source: RouteSource = "default";
35
41
  let score = 0;
42
+ let trace: SwitchTrace | undefined;
43
+ let next: AutoState | undefined;
44
+ let movable = true;
45
+ let prefer: string | undefined;
36
46
  try {
37
- const signals = extractToolSignals(normalizeConversation(body));
38
- const decision = selectStage(signals, { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, priorState);
39
- if (sessionKey) this.states.set(sessionKey, decision.state);
47
+ const conversation = normalizeConversation(body);
48
+ const decision = selectStage(extractToolSignals(conversation), { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, prior?.stage ?? { capableHoldTurnsRemaining: 0 });
49
+ const inputTokens = this.estimateInputTokens(body.messages, body.tools);
50
+ const fits = (candidate: Tier) => { const staged = this.stageBackend(candidate, body, inputTokens); return !!staged && (staged.kind === "mlx" || inputTokens <= this.options.dgxMaxInputTokens); };
51
+ const staged = stayOrSwitch(this.options.staySwitch?.(), prior?.pin?.tier, decision, turnKind(conversation), { inputTokens, fits });
52
+ next = { stage: decision.state, ...(staged.pin ? { pin: { tier: staged.pin, alias: "" } } : {}) };
53
+ trace = staged.trace;
40
54
  score = decision.score;
41
55
  source = this.sourceOf(decision.source);
42
- const selectedTier = decision.tier ?? decision.defaultTier;
43
- const stagedBackend = this.stageBackend(selectedTier, body);
44
- if (stagedBackend) {
45
- backend = stagedBackend;
46
- tier = this.aliasOf(stagedBackend);
47
- } else {
48
- source = "default";
49
- }
56
+ // Enforced, a tool loop that keeps its tier keeps its backend too: a move inside the tier costs the same prefill (#199).
57
+ movable = !(trace.staySwitch === "enforce" && trace.turnType === "tool_result" && trace.plan === "stay" && trace.reason !== "new_pin");
58
+ if (!movable) prefer = prior?.pin?.alias;
59
+ const stagedBackend = this.stageBackend(staged.tier, body, inputTokens);
60
+ if (stagedBackend) backend = stagedBackend;
61
+ else source = "default";
50
62
  } catch {
51
63
  source = "default";
52
64
  }
@@ -56,27 +68,31 @@ export class AutoRouteSelector {
56
68
  const override = await this.options.selectBackend(body);
57
69
  if (this.isAllowed(override)) {
58
70
  backend = override;
59
- tier = this.aliasOf(override);
60
71
  source = "override";
61
72
  }
62
73
  } catch { /* deterministic stage choice is the fail-open route */ }
63
74
  }
64
75
 
65
- this.emit({ route: "hub/auto", tier, source, score, ms: Math.max(0, performance.now() - started) });
66
- return backend;
76
+ if (next?.pin) next.pin.alias = this.aliasOf(backend);
77
+ if (sessionKey && next) this.states.set(sessionKey, next);
78
+ const route: RelayRouteEvent = { route: "hub/auto", tier: this.aliasOf(backend), source, score, ms: Math.max(0, performance.now() - started), ...trace, ...(sessionKey ? {} : { stateless: true }) };
79
+ return { backend, route, movable, ...(prefer ? { prefer } : {}), moved: (alias, moveSource) => {
80
+ route.tier = alias;
81
+ route.source = moveSource;
82
+ if (next?.pin) next.pin.alias = alias;
83
+ } };
67
84
  }
68
85
 
69
- private stageBackend(tier: "capable" | "efficient", body: RelayRequest): ModelBackend | undefined {
86
+ private stageBackend(tier: "capable" | "efficient", body: RelayRequest, input: number): ModelBackend | undefined {
70
87
  if (tier === "capable") return "dgx/coding" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/coding" } : undefined;
71
- if (this.options.mlx && this.mlxFitsBudget(body)) return { kind: "mlx", alias: this.mlxAlias };
88
+ if (this.options.mlx && this.mlxFitsBudget(body, input)) return { kind: "mlx", alias: this.mlxAlias };
72
89
  return "dgx/fast" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/fast" } : undefined;
73
90
  }
74
91
 
75
- private mlxFitsBudget(body: RelayRequest): boolean {
92
+ private mlxFitsBudget(body: RelayRequest, input: number): boolean {
76
93
  const context = Math.min(8192, this.options.mlx?.contextWindow ?? 8192);
77
94
  const maxOutput = this.options.mlx?.maxTokens ?? 2048;
78
95
  const output = body.max_tokens ?? maxOutput;
79
- const input = this.estimateInputTokens(body.messages, body.tools);
80
96
  const inputLimit = this.options.mlx?.maxInputTokens ?? (this.options.mlx?.provider === "ollama" ? 6000 : 16_000);
81
97
  return Number.isInteger(output) && output > 0 && output <= maxOutput && input <= inputLimit && input + output <= context;
82
98
  }
@@ -95,8 +111,4 @@ export class AutoRouteSelector {
95
111
  if (source === "llm-classifier") return "classifier";
96
112
  return "default";
97
113
  }
98
-
99
- private emit(event: RelayRouteEvent): void {
100
- try { this.options.onRoute?.(event); } catch { /* observation cannot fail routing */ }
101
- }
102
114
  }
@@ -3,22 +3,24 @@ import { AdvisorGate, buildAdvisorJudgeRequest, redoFeedback } from "./advisor.t
3
3
  import type { HubRoute } from "./config.ts";
4
4
  import { buildEscalationJudgeRequest, parseEscalationVerdict } from "./escalation.ts";
5
5
  import { normalizeConversation } from "./normalize.ts";
6
- import { extractToolSignals } from "./signals.ts";
6
+ import { extractToolSignals, turnKind } from "./signals.ts";
7
7
  import { RouteLabelTracker, type RouteLabelEvent, type RouteTurnContext, type RouteTurnOutcome } from "./labels.ts";
8
- import { dimensionsFromSignal, selectStage, type StageState } from "./stage.ts";
8
+ import { dimensionsFromSignal, estimateInputTokens, selectStage, stayOrSwitch, type StageState, type StaySwitchPolicy, type SwitchTrace, type Tier } from "./stage.ts";
9
9
  import { EscalationState, SessionState } from "./state.ts";
10
10
  import { parseAdvisorVerdict } from "./text.ts";
11
11
  import { planExecutePhase, type PlanExecuteState } from "./plan-execute.ts";
12
12
 
13
- export interface RouteEvent {
13
+ export interface RouteEvent extends Partial<SwitchTrace> {
14
14
  route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default"; score: number; ms: number;
15
15
  decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number;
16
16
  }
17
17
  export interface AdvisorEvent { route: string; trigger: string; verdict: "approve" | "redo" | "failed"; discardedChars: number }
18
- interface RouteState { fingerprint: string; stage: StageState; plan: PlanExecuteState; escalation: EscalationState; advisor: AdvisorGate }
18
+ interface RouteState { fingerprint: string; stage: StageState; pin?: Tier; plan: PlanExecuteState; escalation: EscalationState; advisor: AdvisorGate }
19
19
  export interface RouteHost {
20
20
  execute: (model: string, messages: ChatMessage[], judge: boolean, signal: AbortSignal, maxTokens?: number) => Promise<ChatResult>;
21
21
  onCampus: () => Promise<boolean>;
22
+ /** Read on every stage call (#197); absent is the shadow default. */
23
+ staySwitch?: () => StaySwitchPolicy | undefined;
22
24
  onRoute?: (event: RouteEvent) => void;
23
25
  onRouteOutcome?: (event: RouteLabelEvent) => void;
24
26
  onAdvisor?: (event: AdvisorEvent) => void;
@@ -59,11 +61,19 @@ export class HubRouteRuntime {
59
61
  const dimensions = dimensionsFromSignal(signals);
60
62
  const efficient = config.efficient ?? "fast", capable = config.capable ?? "coding";
61
63
  let model = efficient, source: RouteEvent["source"] = "default", score = 0;
62
- let sent = messages;
64
+ let sent = messages, trace: Partial<SwitchTrace> = {};
63
65
  if (config.type === "stage") {
64
66
  const decision = selectStage(signals, { confidenceThreshold: config.confidence_threshold, capableHoldTurns: config.hold_turns }, state.stage);
65
67
  state.stage = decision.state;
66
- model = (decision.tier ?? decision.defaultTier) === "capable" ? capable : efficient;
68
+ // OmniRoute models publish no context limit to the hub, so every tier fits here; the estimate leaves out the tool
69
+ // schemas the host adds to each call, as the relay counts what Pi sends. A PII conversation's size says
70
+ // something about its text, as a private envelope's does: the planner never sees it (no prefill bound, so no
71
+ // plan or reason can depend on it) and the event leaves it out.
72
+ const staged = stayOrSwitch(this.host.staySwitch?.(), state.pin, decision, turnKind(conversation), { inputTokens: pii ? 0 : estimateInputTokens(messages), fits: () => true });
73
+ state.pin = staged.pin;
74
+ const { prefillTokens, ...shape } = staged.trace;
75
+ trace = pii ? shape : staged.trace;
76
+ model = staged.tier === "capable" ? capable : efficient;
67
77
  score = decision.score;
68
78
  source = decision.source === "capable_hold" ? "hold" : decision.source === "override" || decision.source === "dimensions" ? decision.source : "default";
69
79
  } else if (config.type === "plan_execute") {
@@ -72,7 +82,7 @@ export class HubRouteRuntime {
72
82
  state.plan = decision.state;
73
83
  if (decision.planningPrompt) sent = [{ role: "system", content: decision.planningPrompt }, ...messages];
74
84
  } else if (config.type === "escalation" && state.escalation.snapshot().latched) model = capable;
75
- let decisionId = this.emitRoute({ route, tier: model, source, score, ms: performance.now() - started }, labelTurn, dimensions, pii, messages);
85
+ let decisionId = this.emitRoute({ route, tier: model, source, score, ms: performance.now() - started, ...trace }, labelTurn, dimensions, pii, messages);
76
86
  const result = await this.host.execute(model, sent, false, signal);
77
87
  if (config.type !== "escalation" || state.escalation.snapshot().latched) return this.withDecision(result, decisionId);
78
88
  const request = buildEscalationJudgeRequest(normalizeConversation([...messages, result.message]), signals.assistantTurnCount + 1);
@@ -217,16 +217,27 @@ function buildSignals(calls: Array<{name:string;command?:string;source?:string}>
217
217
  return signal;
218
218
  }
219
219
 
220
+ const COMPACTED = 'session is being continued';
221
+
220
222
  export function extractToolSignals(conversation: Conversation, recentWindow = DEFAULT_RECENT_WINDOW): ToolSignals {
221
223
  const calls: Array<{name:string;command?:string}> = []; const results: Result[]=[]; const ids=new Map<string,boolean>(); let assistants=0; let compacted=false;
222
224
  for(const message of conversation.messages){if(message.role==='assistant')assistants++;
223
- if(message.content.toLowerCase().includes('session is being continued'))compacted=true;
225
+ if(message.content.toLowerCase().includes(COMPACTED))compacted=true;
224
226
  for(const call of message.toolCalls){const command=argumentCommand(call.arguments);calls.push({name:call.name,...(command!==undefined?{command}:{})}); ids.set(call.id,semantic(call.name,command)==='read');}
225
227
  for(const result of message.toolResults){const retrieval=ids.get(result.toolCallId)===true&&!result.isError;results.push({text:retrieval?'':result.content,isError:result.isError===true});}
226
228
  }
227
229
  return buildSignals(calls,results,conversation.messages.length,assistants,compacted,recentWindow);
228
230
  }
229
231
 
232
+ export type TurnKind = 'tool_result' | 'user' | 'compaction';
233
+
234
+ /** What a call answers (#197, not ported): its last message is a tool result, a compaction summary or a user message. */
235
+ export function turnKind(conversation: Conversation): TurnKind {
236
+ const last = conversation.messages.at(-1);
237
+ if (last?.toolResults.length) return 'tool_result';
238
+ return last?.content.toLowerCase().includes(COMPACTED) ? 'compaction' : 'user';
239
+ }
240
+
230
241
  export function extractToolSignalsFromObservations(observations: readonly ToolObservation[], turnDepth = observations.length, recentWindow = DEFAULT_RECENT_WINDOW): ToolSignals {
231
242
  const calls=observations.map((o)=>({name:o.name, ...(o.command!==undefined?{command:o.command}:{}), ...(o.source!==undefined?{source:o.source}:{})}));
232
243
  const results=observations.filter((o)=>o.resultText!==undefined||o.isError===true).map((o)=>({text:!o.isError && semantic(o.name,o.command,o.source)==='read' ? '' : o.resultText??'',isError:o.isError===true}));
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Ported to TypeScript from NVIDIA NeMo Switchyard crates/libsy/src/algorithms/util/stage.rs at c8848511, modified.
4
4
 
5
- import type { ToolSignals } from './signals.ts';
5
+ import type { ToolSignals, TurnKind } from './signals.ts';
6
6
 
7
7
  export type Tier = 'capable' | 'efficient';
8
8
  export type PickerMode = 'capable_first' | 'efficient_first';
@@ -19,7 +19,8 @@ export interface HandoffNoteConfig {
19
19
  onlyOnWrongSignalEscalation?: boolean;
20
20
  }
21
21
  export interface StageOptions { mode?:PickerMode; confidenceThreshold?:number; capableHoldTurns?:number; handoffNotes?:HandoffNoteConfig }
22
- export interface StageDecision { tier:Tier|undefined; defaultTier:Tier; source:DecisionSource; probability:number; confidence:number; score:number; dimensions:CodingAgentDimensions; note?:string; state:StageState }
22
+ /** `hardOverride` (#197, not ported): pickTier's override predicate holds, even when a capable hold answered first. */
23
+ export interface StageDecision { tier:Tier|undefined; defaultTier:Tier; source:DecisionSource; probability:number; confidence:number; score:number; dimensions:CodingAgentDimensions; note?:string; state:StageState; hardOverride:boolean }
23
24
 
24
25
  const STALL_MIN_TURN_DEPTH=8;
25
26
  const SCORE_GAIN=5;
@@ -46,9 +47,14 @@ export function scoreSignal(signal:ToolSignals):ScoreResult {
46
47
  return {score,confidence:Math.abs(score)};
47
48
  }
48
49
 
50
+ /** Compaction, critical severity or a repeated failure: capable whatever the scores say. */
51
+ export function hardOverride(signal:ToolSignals):boolean {
52
+ return signal.compacted||signal.severity>=SEVERITY_CRITICAL||signal.repeatedFailure;
53
+ }
54
+
49
55
  export function pickTier(signal:ToolSignals, mode:PickerMode='efficient_first', confidenceThreshold=0.5):PickOutcome {
50
56
  const defaultTier:Tier=mode==='capable_first'?'capable':'efficient';
51
- if(signal.compacted||signal.severity>=SEVERITY_CRITICAL||signal.repeatedFailure)
57
+ if(hardOverride(signal))
52
58
  return {kind:'resolved',tier:'capable',source:'override',probability:0.5,confidence:1};
53
59
  const scored=scoreSignal(signal); const probability=(scored.score+1)/2; const half=confidenceThreshold/2;
54
60
  if(probability>0.5+half||probability<0.5-half)
@@ -89,5 +95,53 @@ export function selectStage(signal:ToolSignals, options:StageOptions={}, state:S
89
95
  dimensions,
90
96
  ...(note === undefined ? {} : { note }),
91
97
  state: { capableHoldTurnsRemaining: remaining },
98
+ hardOverride: hardOverride(signal),
92
99
  };
93
100
  }
101
+
102
+ // Session-aware stay/switch (#197): not ported from Switchyard; the rules follow Weave Router's planner, without its
103
+ // history summary on a switch.
104
+ export type StaySwitchMode = 'off' | 'shadow' | 'enforce';
105
+ /** The routing.toml keys the planner reads. */
106
+ export interface StaySwitchPolicy { stay_switch: StaySwitchMode; max_switch_prefill_tokens: number }
107
+ // ponytail: an unmeasured prefill bound; #197 AC6 measures prefill before enforce becomes the default.
108
+ export const DEFAULT_STAY_SWITCH: StaySwitchPolicy = { stay_switch: 'shadow', max_switch_prefill_tokens: 32_000 };
109
+ export type SwitchReason = 'new_pin' | 'compaction' | 'context_fit' | 'same_tier' | 'override' | 'tool_loop' | 'prefill_bound' | 'user_turn';
110
+ export interface SwitchCost { inputTokens: number; maxSwitchPrefillTokens: number; fits: (tier: Tier) => boolean }
111
+ /** Input tokens a backend would prefill for a conversation, 4 characters each; the relay and local routes share it. */
112
+ export function estimateInputTokens(messages: unknown, tools?: unknown[]): number {
113
+ return Math.ceil(JSON.stringify({ messages, tools }).length / 4);
114
+ }
115
+ export interface SwitchPlan { plan: 'stay' | 'switch'; tier: Tier; reason: SwitchReason }
116
+ /** What a route event records about the planner; never prompt text. */
117
+ export interface SwitchTrace { turnType: TurnKind; prefillTokens: number; staySwitch?: 'shadow' | 'enforce'; plan?: 'stay' | 'switch'; reason?: SwitchReason }
118
+
119
+ /**
120
+ * Stay on the session's pinned tier or switch to this call's stage decision. A new session or a compaction starts a fresh
121
+ * pin, a hard override escalates on any turn, a tool loop keeps its tier, and other changes wait for a user turn; a
122
+ * de-escalation that makes the efficient backend prefill more than the bound stays. A tier whose backend cannot hold the
123
+ * conversation is never chosen: nothing here summarizes or trims the history to make a switch fit.
124
+ */
125
+ export function planSwitch(pin: Tier | undefined, fresh: Pick<StageDecision, 'tier' | 'defaultTier' | 'source'> & { hardOverride?: boolean }, turn: TurnKind, cost: SwitchCost): SwitchPlan {
126
+ const wanted = fresh.tier ?? fresh.defaultTier, other: Tier = wanted === 'capable' ? 'efficient' : 'capable';
127
+ const want = cost.fits(wanted) || !cost.fits(other) ? wanted : other;
128
+ const plan = (tier: Tier, reason: SwitchReason): SwitchPlan => ({ plan: pin === undefined || pin === tier ? 'stay' : 'switch', tier, reason });
129
+ if (pin === undefined) return plan(want, 'new_pin');
130
+ if (turn === 'compaction') return plan(want, 'compaction');
131
+ if (want !== wanted || !cost.fits(pin)) return plan(want, 'context_fit');
132
+ if (want === pin) return plan(pin, 'same_tier');
133
+ // A hold answers before pickTier's overrides, so the override is read on its own: it escalates even during a hold.
134
+ if (fresh.source === 'override' || fresh.hardOverride) return plan(want, 'override');
135
+ if (turn === 'tool_result') return plan(pin, 'tool_loop');
136
+ if (want === 'efficient' && cost.inputTokens > cost.maxSwitchPrefillTokens) return plan(pin, 'prefill_bound');
137
+ return plan(want, 'user_turn');
138
+ }
139
+
140
+ /** The one place hub/auto and local stage routes apply the mode, so both record the same trace. `shadow` and `off` keep the stage tier. */
141
+ export function stayOrSwitch(policy: StaySwitchPolicy | undefined, pin: Tier | undefined, fresh: StageDecision, turn: TurnKind, cost: Omit<SwitchCost, 'maxSwitchPrefillTokens'>): { tier: Tier; pin?: Tier; trace: SwitchTrace } {
142
+ const { stay_switch: mode, max_switch_prefill_tokens: maxSwitchPrefillTokens } = policy ?? DEFAULT_STAY_SWITCH;
143
+ const tier = fresh.tier ?? fresh.defaultTier, trace: SwitchTrace = { turnType: turn, prefillTokens: cost.inputTokens };
144
+ if (mode === 'off') return { tier, trace };
145
+ const planned = planSwitch(pin, fresh, turn, { ...cost, maxSwitchPrefillTokens });
146
+ return { tier: mode === 'enforce' ? planned.tier : tier, pin: planned.tier, trace: { ...trace, staySwitch: mode, plan: planned.plan, reason: planned.reason } };
147
+ }
@@ -158,7 +158,7 @@ export class OmniRoute {
158
158
  }
159
159
  }
160
160
 
161
- async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number }, opts: ChatOptions = {}): Promise<ChatResult> {
161
+ async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number; temperature?: number; reasoning_effort?: string }, opts: ChatOptions = {}): Promise<ChatResult> {
162
162
  const base = opts.via ?? (await this.base());
163
163
  if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
164
164
  if (opts.onCampusOnly && (opts.via || this.isAccessHost(base))) throw new Error("PII model calls require the confirmed campus gateway");
@@ -17,3 +17,26 @@ export function processSignature(pid: number): string | undefined {
17
17
  return text ? createHash("sha256").update(text).digest("hex") : undefined;
18
18
  } catch { return undefined; }
19
19
  }
20
+
21
+ export type Liveness = "live" | "gone" | "unknown";
22
+
23
+ /**
24
+ * #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
25
+ * owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
26
+ * pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
27
+ * identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
28
+ * always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
29
+ */
30
+ // ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
31
+ // live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
32
+ // (Linux /proc/<pid>/stat field 22) component in the signature.
33
+ export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
34
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
35
+ if (signature) {
36
+ let current: string | undefined;
37
+ try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
38
+ if (current) return current === signature ? "live" : "gone";
39
+ }
40
+ try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
41
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
42
+ }
@@ -1,6 +1,7 @@
1
1
  import { chmodSync } from "node:fs";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { createHash } from "node:crypto";
4
+ import { PreEffectToolRefusal } from "../hub/tool-refusal.ts";
4
5
 
5
6
  /** Private, session-scoped dedupe. An interrupted effect is never automatically repeated. */
6
7
  export class PiToolReceipts {
@@ -31,10 +32,15 @@ export class PiToolReceipts {
31
32
  }
32
33
  const claim = this.db.query("INSERT OR IGNORE INTO pi_tool_receipts(session,call_id,fingerprint,state) VALUES(?,?,?,'pending')").run(session, callId, fingerprint);
33
34
  if (!claim.changes) return this.execute(session, callId, name, args, run);
34
- const work = Promise.resolve().then(run).then((result) => {
35
+ const settle = (result: string): string => {
35
36
  const text = result.slice(0, 100_000);
36
37
  this.db.query("UPDATE pi_tool_receipts SET state='done',result=? WHERE session=? AND call_id=?").run(text, session, callId);
37
38
  return text;
39
+ };
40
+ // Legacy pending rows have no refusal proof: neither the current board nor an error's text can settle them.
41
+ const work = Promise.resolve().then(run).then(settle, (error: unknown) => {
42
+ if (error instanceof PreEffectToolRefusal) return settle(`error: ${error.message}`);
43
+ throw error;
38
44
  }).catch(() => "error: tool outcome is uncertain; stop and reconcile it, do not repeat the effect")
39
45
  .finally(() => this.pending.delete(key));
40
46
  this.pending.set(key, work);
package/src/ui/index.html CHANGED
@@ -4,17 +4,27 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agent-hub | Local dashboard</title>
7
+ <script>
8
+ const dashboardTheme = (() => {
9
+ let choice = 'system';
10
+ try { const saved = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith('agent-hub-theme='))?.slice('agent-hub-theme='.length); if (['system','light','dark'].includes(saved)) choice = saved; } catch {}
11
+ const apply = () => { document.documentElement.dataset.theme = choice; };
12
+ apply();
13
+ return { get: () => choice, set: (value) => { choice = ['system','light','dark'].includes(value) ? value : 'system'; apply(); try { document.cookie = `agent-hub-theme=${choice}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} } };
14
+ })();
15
+ </script>
7
16
  <style>
8
- :root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
17
+ :root{color-scheme:light;--waiting:#795399;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}:root[data-theme="dark"]{color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
18
+ .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.theme-choice{width:auto}
9
19
  </style>
10
20
  </head>
11
21
  <body>
12
- <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div id="connection" class="connection" role="status">Connecting...</div></header>
22
+ <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div></header>
13
23
  <main>
14
24
  <p id="notice" class="notice" role="status" aria-live="polite"></p>
15
25
  <div class="stack">
16
26
  <section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
17
- <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
27
+ <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
18
28
  <section aria-labelledby="compose-title"><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
19
29
  </div>
20
30
  <div class="stack">
@@ -22,11 +32,14 @@
22
32
  <section aria-labelledby="peers-title"><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
23
33
  <section aria-labelledby="permissions-title"><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
24
34
  <section aria-labelledby="budget-title"><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
35
+ <section aria-labelledby="bench-title"><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
25
36
  </div>
26
37
  </main>
27
38
  <footer>Loopback session. Private task content stays in the terminal. Reopen with <code>ahub ui</code> when your session expires.</footer>
28
39
  <script>
29
40
  'use strict';
41
+ /* TASK_PROGRESS_MODEL */
42
+ const themeControl = document.getElementById('theme'); themeControl.value = dashboardTheme.get(); themeControl.addEventListener('change', () => dashboardTheme.set(themeControl.value));
30
43
  let ticket = location.hash.slice(1);
31
44
  history.replaceState(null, '', location.pathname);
32
45
  const $ = (id) => document.getElementById(id);
@@ -98,7 +111,7 @@ function resetView(id, instance) {
98
111
  signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
99
112
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
100
113
  $('stream-empty').hidden = true; $('task-filter').value = '';
101
- for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
114
+ for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
102
115
  $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
103
116
  notice('');
104
117
  draftChoicesPending = managerMode && Boolean(selectedProjectId);
@@ -146,6 +159,25 @@ async function refreshProjects() {
146
159
  if (!stopped) projectsTimer = setTimeout(refreshProjects, 3000);
147
160
  }
148
161
  function peerOptions(select, ids, first) { const selected = select.value; select.replaceChildren(); const initial = el('option', first); initial.value = ''; select.append(initial); for (const id of ids) { const option = el('option', id); option.value = id; select.append(option); } if (ids.includes(selected)) select.value = selected; }
162
+ function renderTaskProgress(progress) {
163
+ update('task-progress', progress, (target) => {
164
+ const percent = progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0;
165
+ target.append(el('strong', `${progress.counts.approved}/${progress.total} approved (${percent}%)`));
166
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 100 12'); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');
167
+ let x = 0;
168
+ for (const [state, count] of Object.entries(progress.counts)) {
169
+ if (!count) continue;
170
+ const width = progress.total ? count / progress.total * 100 : 0;
171
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', String(x)); rect.setAttribute('y', '0'); rect.setAttribute('width', String(width)); rect.setAttribute('height', '12'); rect.setAttribute('class', 'progress-' + state); svg.append(rect); x += width;
172
+ }
173
+ target.append(svg, el('p', Object.entries(progress.counts).map(([state, count]) => state.replaceAll('_', ' ') + ' ' + count).join(' / '), 'meta'));
174
+ });
175
+ }
176
+ function renderTaskStage(stage) {
177
+ const row = el('div', undefined, 'task-stage' + (stage.changes ? ' changes' : ''));
178
+ for (const [index, label] of ['Proposed', 'In progress', 'In review', 'Approved'].entries()) row.append(el('span', `${index < stage.stage ? '[x]' : '[ ]'} ${label}${index < 3 ? ' > ' : ''}`, index < stage.stage ? 'reached' : ''));
179
+ row.append(el('span', ` / Stage ${stage.stage}/4: ${stage.label}`)); return row;
180
+ }
149
181
  function render(snapshot) {
150
182
  const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
151
183
  $('peer-count').textContent = ids.length + ' peers';
@@ -159,6 +191,7 @@ function render(snapshot) {
159
191
  if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
160
192
  if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
161
193
  if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
194
+ if (peer.toolsOnly) row.append(el('p', peer.toolsOnly, 'meta'));
162
195
  if (peer.paused) row.append(el('p', peer.paused, 'meta'));
163
196
  if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
164
197
  for (const backend of peer.backends || []) {
@@ -171,16 +204,20 @@ function render(snapshot) {
171
204
  const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
172
205
  const peerSignature = JSON.stringify([ids, assignable]);
173
206
  if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
207
+ const progress = taskProgress(snapshot.tasks);
208
+ renderTaskProgress(progress);
209
+ const progressStages = new Map(progress.stages.map(stage => [stage.id, stage]));
174
210
  $('task-count').textContent = snapshot.tasks.length + ' tasks';
175
211
  const taskFilter = $('task-filter').value;
176
212
  const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
177
- update('tasks', [visibleTasks, ids, taskFilter, snapshot.tasks.length], (target) => {
213
+ update('tasks', [visibleTasks, ids, taskFilter, progress], (target) => {
178
214
  if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
179
215
  if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
180
216
  for (const task of visibleTasks) {
181
217
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
182
218
  head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
183
- row.append(head, el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
219
+ const stage = progressStages.get(task.id);
220
+ row.append(head, renderTaskStage(stage), el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
184
221
  if (task.detail) row.append(el('p', task.detail, 'body'));
185
222
  if (task.state !== 'approved' && ids.length) {
186
223
  const actions = el('div', undefined, 'actions'), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
@@ -225,6 +262,7 @@ function render(snapshot) {
225
262
  target.append(row);
226
263
  }
227
264
  });
265
+ renderBench(snapshot.bench);
228
266
  const stream = $('stream');
229
267
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
230
268
  for (const entry of snapshot.events || []) {
@@ -262,6 +300,46 @@ const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence',
262
300
  let streamFilter = '';
263
301
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
264
302
  function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
303
+ // #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
304
+ function renderBench(bench) {
305
+ const pct = (v) => v === null || v === undefined ? '-' : Math.round(v * 100) + '%';
306
+ const num = (v) => v === null || v === undefined ? '-' : String(Math.round(v));
307
+ const dur = (ms) => ms === null || ms === undefined ? '-' : ms < 120000 ? Math.round(ms / 1000) + 's' : Math.round(ms / 60000) + 'm';
308
+ $('bench-count').textContent = bench ? bench.runs.length + ' runs' : '';
309
+ update('bench', bench || null, (target) => {
310
+ if (!bench) return empty(target, 'No benchmark runs. In a project kept for benchmarks, run ahub bench run <suite.json> --arm <label>.');
311
+ for (const run of bench.runs) {
312
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
313
+ const tone = run.state === 'running' ? 'info' : run.state === 'finished' ? 'ok' : run.state === 'unknown' ? '' : 'warn';
314
+ head.append(el('span', run.suite + ' [' + run.arm + ']', 'label'), el('span', run.state, 'badge ' + tone));
315
+ const bar = el('progress'); bar.max = run.total || 1; bar.value = run.done; bar.setAttribute('aria-label', run.suite + ' ' + run.arm + ': ' + run.done + ' of ' + run.total + ' attempts');
316
+ row.append(head);
317
+ if (run.state === 'running') row.append(el('p', 'now: ' + (run.current || '-') + ' / running ' + dur(Date.now() - Date.parse(run.startedAt)) + ' / ' + (run.total - run.done) + ' attempts left', 'meta'));
318
+ row.append(bar, el('p', run.done + '/' + run.total + ' attempts / pass ' + pct(run.passRate) + ' (' + run.pass + ' pass, ' + run.fail + ' fail, ' + run.timeout + ' timeout, ' + run.error + ' error) / tokens p50 ' + num(run.tokensMedian) + ' / wall p50 ' + dur(run.wallMsMedian), 'meta'));
319
+ target.append(row);
320
+ }
321
+ const suites = new Map();
322
+ for (const arm of bench.arms) { if (!suites.has(arm.suite)) suites.set(arm.suite, []); suites.get(arm.suite).push(arm); }
323
+ for (const [suite, arms] of suites) {
324
+ if (arms.length < 2) continue;
325
+ const block = el('div', undefined, 'row');
326
+ block.append(el('div', 'Compare arms: ' + suite, 'label'));
327
+ for (const [key, label, rate, fmt] of [['passRate', 'Pass rate', true, pct], ['firstPassRate', 'First pass', true, pct], ['tokensMedian', 'Tokens per attempt (p50)', false, num], ['wallMsMedian', 'Wall time per attempt (p50)', false, dur]]) {
328
+ const values = arms.map((a) => a.measures[key]).filter((v) => v !== null && v !== undefined);
329
+ const max = rate ? 1 : Math.max(1, ...values);
330
+ block.append(el('p', label, 'meta'));
331
+ for (const arm of arms) {
332
+ const value = arm.measures[key];
333
+ const counted = key === 'passRate' || key === 'wallMsMedian' ? arm.measures.scored : arm.measures.measured;
334
+ const line = el('p', arm.arm + ': ' + fmt(value) + ' (' + counted + ' counted of ' + arm.measures.attempts + ' attempts, ' + arm.runs + ' runs)' + (counted < bench.minAttempts ? ', too few to compare' : ''), 'meta');
335
+ const bar = el('progress'); bar.max = max; bar.value = value === null || value === undefined ? 0 : value; bar.setAttribute('aria-label', label + ', ' + arm.arm + ': ' + fmt(value));
336
+ block.append(line, bar);
337
+ }
338
+ }
339
+ target.append(block);
340
+ }
341
+ });
342
+ }
265
343
  function renderStreamChips() {
266
344
  const group = $('stream-filters');
267
345
  if (group.children.length) return;