@staix/agent-hub 0.12.44 → 0.13.0

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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
@@ -11,14 +11,9 @@ import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./regist
11
11
  import { types } from "node:util";
12
12
 
13
13
  export type * from "./registry-types.ts";
14
- export const ADAPTER_API = 1;
14
+ import { ADAPTER_API } from "./adapter-api.ts";
15
+ export { ADAPTER_API };
15
16
 
16
- /** Ordered compatibility projections stay typed independently of definition implementation inference. */
17
- export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
18
- export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
19
- export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
20
- export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
21
- export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
22
17
 
23
18
 
24
19
  function immutable<T>(value: T, seen = new WeakSet<object>()): T {
@@ -38,6 +33,31 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
38
33
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
39
34
  AGY_DEFINITION,
40
35
  ]);
36
+ export interface AdapterLists {
37
+ readonly KNOWN_PEERS: readonly string[]; readonly OPTIONAL_PEERS: readonly string[]; readonly SUPPORTED_PEERS: readonly string[];
38
+ readonly START_MODE_PEERS: readonly string[]; readonly ROLE_ORDER: readonly string[]; readonly HELP_ORDER: readonly string[]; readonly PLANNER_PEERS: readonly string[];
39
+ }
40
+ /** Pure builtin projections. Unranked definitions retain their source order after ranked definitions. */
41
+ export function deriveAdapterLists(definitions: readonly AdapterDefinition[]): AdapterLists {
42
+ const ordered = (field: "roleRank" | "startRank" | "helpRank", eligible = (_definition: AdapterDefinition) => true) => definitions
43
+ .map((definition, index) => ({ definition, index })).filter(({ definition }) => eligible(definition))
44
+ .sort((left, right) => {
45
+ const a = left.definition.projection?.[field], b = right.definition.projection?.[field];
46
+ if (a === undefined) return b === undefined ? left.index - right.index : 1;
47
+ if (b === undefined) return -1;
48
+ return a - b || left.index - right.index;
49
+ }).map(({ definition }) => definition.kind);
50
+ const KNOWN_PEERS = definitions.filter(definition => !definition.optional).map(definition => definition.kind);
51
+ const OPTIONAL_PEERS = definitions.filter(definition => definition.optional).map(definition => definition.kind);
52
+ return immutable({ KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS: [...KNOWN_PEERS, ...OPTIONAL_PEERS],
53
+ START_MODE_PEERS: ordered("startRank", definition => definition.start.forms.includes("tui") && definition.start.forms.includes("headless")),
54
+ ROLE_ORDER: ordered("roleRank"), HELP_ORDER: ordered("helpRank"),
55
+ PLANNER_PEERS: definitions.filter(definition => definition.projection?.plannerEligible === true).map(definition => definition.kind),
56
+ });
57
+ }
58
+ export const { KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, ROLE_ORDER, PLANNER_PEERS } = deriveAdapterLists(ADAPTERS);
59
+ export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
60
+
41
61
  // Unavailable declarations are metadata, not supported native peers or launchers.
42
62
  const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
43
63
  const definitions = new Map<string, AdapterDefinition>();
@@ -101,14 +121,21 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
101
121
  }
102
122
 
103
123
  /** Compile one captured, host-trusted declaration set; never register native policy globally. */
104
- export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapterCatalog {
124
+ export function createProjectAdapterCatalog(rawAdapters?: unknown, externalDefinitions: readonly AdapterDefinition[] = []): ProjectAdapterCatalog {
105
125
  const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
106
126
  for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
107
- const entries = Object.freeze([...ADAPTERS, ...configured]);
108
- const catalog = new Map([...definitions, ...configured.map(definition => [definition.kind, definition] as const)]);
127
+ const seen = new Set([...definitions.keys(), ...configured.map(item => item.kind)]);
128
+ for (const definition of externalDefinitions) {
129
+ if (definition.api !== ADAPTER_API || seen.has(definition.kind)) throw new Error("external adapter kind or API collision");
130
+ assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition);
131
+ seen.add(definition.kind);
132
+ }
133
+ const external = immutable([...externalDefinitions]);
134
+ const entries = Object.freeze([...ADAPTERS, ...configured, ...external]);
135
+ const catalog = new Map([...definitions, ...[...configured, ...external].map(definition => [definition.kind, definition] as const)]);
109
136
  const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
110
137
  const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
111
- return Object.freeze({ definitions: entries, kindOf, definitionOf,
138
+ return Object.freeze({ definitions: entries, externalDefinitions: external, kindOf, definitionOf,
112
139
  familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
113
140
  isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
114
141
  if (typeof id !== "string") return false;
@@ -119,8 +146,7 @@ export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapt
119
146
  }
120
147
 
121
148
  /** Compatibility projection order is observable in default role maps and planner selection. */
122
- const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
123
- export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
149
+
124
150
 
125
151
  /** Callers own their role arrays, independent of immutable native policy. */
126
152
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
@@ -159,6 +185,12 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
159
185
  if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
160
186
  fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
161
187
  }
188
+ const projection = definition.projection;
189
+ if (projection !== undefined) {
190
+ if (!projection || typeof projection !== "object" || Array.isArray(projection) || Object.keys(projection).some(key => !["roleRank", "startRank", "helpRank", "plannerEligible"].includes(key))) fail("projection", "invalid projection metadata");
191
+ for (const field of ["roleRank", "startRank", "helpRank"] as const) if (projection[field] !== undefined && (typeof projection[field] !== "number" || !Number.isFinite(projection[field]))) fail(`projection.${field}`, "rank must be finite");
192
+ if (projection.plannerEligible !== undefined && typeof projection.plannerEligible !== "boolean") fail("projection.plannerEligible", "boolean required");
193
+ }
162
194
  const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
163
195
  if (pullOnly !== undefined) {
164
196
  if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
@@ -199,9 +231,14 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
199
231
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
200
232
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
201
233
  ["routing", definition.routing !== undefined],
234
+ ["projection", definition.projection !== undefined],
235
+ ["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
202
236
  ["reviewerVariant", definition.reviewerVariant !== undefined],
203
237
  ["reviewer", definition.reviewer !== undefined],
204
238
  ["hooks.factsHook", declared(definition.hooks, "factsHook")],
239
+ ["cli.entry", declared(definition.cli, "entry")],
240
+ ["cli.preview", declared(definition.cli, "preview")],
241
+ ["cli.usage", declared(definition.cli, "usage")],
205
242
  ];
206
243
  const field = restricted.find(([, present]) => present)?.[0];
207
244
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
@@ -236,8 +273,8 @@ export function builtinPreviewOf(id: string) {
236
273
  /** Fixed builtin doctor metadata and pure observed-value projections; the CLI retains all probe effects. */
237
274
  export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
238
275
 
239
- /** Internal fixed builtin CLI entries; public AdapterDefinition and NativeHooks expose no execution extension. */
240
- export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries.map(entry => {
276
+ /** Definition-owned CLI entries are restricted to fixed builtins, never config or external execution extensions. */
277
+ export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries(ADAPTERS).map(entry => {
241
278
  const definition = definitionOf(entry.command);
242
279
  if (!definition) throw new Error("builtin CLI entry has no adapter definition");
243
280
  return Object.freeze({ ...entry, definition });
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writeTrust, type CompatId, type CompatVerdict } from "../adapters/compat.ts";
3
+
4
+ /** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
5
+ export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
6
+
7
+ /** `commands` carries the configured argv (codex_bin, jcode_cmd, qwen_cmd); the probe words follow it. */
8
+ export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
9
+ const trusted = readTrust();
10
+ return (Object.keys(COMPAT) as CompatId[]).map(id => {
11
+ const [defaultBin, ...words] = COMPAT[id].probe;
12
+ const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
+ const executable = Bun.which(bin) ?? undefined;
14
+ if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
15
+ const result = spawnSync(executable, [...prefix, ...words], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" } });
16
+ return { id, executable, verdict: assessVersion(id, result.status === 0 ? COMPAT[id].parse(result.stdout ?? "") : undefined, trusted), policy: describe(id) };
17
+ });
18
+ }
19
+
20
+ export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
21
+ const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
22
+ if (sub === "trust") {
23
+ const revoke = rest.includes("--revoke");
24
+ if (!isCompatId(id) || !version || rest.some(arg => arg !== "--revoke")) fail(`usage: ahub agents trust <${Object.keys(COMPAT).join("|")}> <version> [--revoke]`);
25
+ // The issue requires saying what trust covers before it is recorded.
26
+ if (!revoke) console.log(`${COMPAT[id].label} ${version}: ${COMPAT[id].trustNote}`);
27
+ let rows;
28
+ try { rows = writeTrust(id, version, revoke); } catch (error) { fail((error as Error).message); }
29
+ if (revoke) return console.log(`revoked: ${id} ${version} (${rows.length} trusted on this machine, ${trustFile()})`);
30
+ console.log(`trusted on this machine: ${COMPAT[id].label} ${version}\nUndo: ahub agents trust ${id} ${version} --revoke`);
31
+ return;
32
+ }
33
+ if (sub !== undefined) fail("usage: ahub agents [--json] | ahub agents trust <id> <version> [--revoke]");
34
+ const rows = agentVersions(commands);
35
+ if (args.includes("--json")) return console.log(JSON.stringify(rows.map(row => ({ id: row.id, executable: row.executable ?? null, ...row.verdict, policy: row.policy })), null, 2));
36
+ for (const row of rows) {
37
+ const v = row.verdict;
38
+ console.log(`${row.id.padEnd(13)} ${(v.version ?? "-").padEnd(10)} ${v.state.padEnd(9)} ${row.policy}${v.ok ? (v.note ? `\n${" ".repeat(14)}${v.note}` : "") : `\n${" ".repeat(14)}${v.reason}`}`);
39
+ }
40
+ }
@@ -0,0 +1,54 @@
1
+ // #711 / #380: the console's review guidance. Pure strings and decisions, no terminal code and no state import: console-state.ts renders them.
2
+ // Nothing here changes authority: the console runs `ahub review` as the person, and the hub decides.
3
+
4
+ export type Verdict = "approved" | "changes_requested";
5
+ /** The task a review choice was opened for, taken from the selected row or the open detail. */
6
+ export interface ReviewChoice { id: number; title: string; reviewerIsUser: boolean }
7
+
8
+ const MAX_TITLE = 40;
9
+ const clean = (text: string) => text.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
10
+ /** The title as the choice line shows it: quoted, one line, cut to the width; a private task is `[pii]`. */
11
+ export function choiceTitle(row: { title?: unknown; signals?: unknown }): string {
12
+ const priv = (Array.isArray(row.signals) && row.signals.some(s => s === "pii" || s === "pii_path")) || row.title === "[pii]";
13
+ if (priv) return "[pii]";
14
+ const text = clean(String(row.title ?? ""));
15
+ return JSON.stringify(text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE - 3)}...` : text);
16
+ }
17
+
18
+ /** The choice for a task row, or undefined when it is not one the person can review (not in review). */
19
+ export function reviewChoiceFor(row: any): ReviewChoice | undefined {
20
+ if (!row || row.state !== "in_review" || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0) return undefined;
21
+ return { id: Number(row.id), title: choiceTitle(row), reviewerIsUser: row.reviewer === "user" };
22
+ }
23
+
24
+ /** The prompt line while the choice is open (#711 change 1). The title is already quoted. */
25
+ export const choicePrompt = (c: ReviewChoice): string => `review #${c.id} ${c.title}: a approve c request changes Esc cancel`;
26
+ /** What `a` and `c` prefill on the input line. The trailing space is where the note goes. */
27
+ export const verdictInput = (id: number, verdict: Verdict): string => `review ${id} ${verdict} `;
28
+ /** The guide line shown above the input after a choice (#711 change 1). */
29
+ export const verdictGuide = (id: number, verdict: Verdict): string =>
30
+ verdict === "approved" ? `Enter approves #${id}. You can type a note first. Esc cancels.` : "Say what to fix, then Enter. Esc cancels.";
31
+
32
+ /** The footer hint for a task in review (#711 change 2): the verb, and whose review it is when it is the person's. */
33
+ export const reviewHint = (reviewer: unknown): string => reviewer === "user" ? "your review: r approve or request changes" : "r approve or request changes";
34
+
35
+ /**
36
+ * A `review <id> <verdict> [note]` line the person typed, checked before it is sent: a changes request must say what to fix (#711 change 1). Returns the refusal,
37
+ * or undefined when the line is fine or is not a review line at all (other commands are validated where they always were).
38
+ */
39
+ export function reviewRefusal(args: readonly string[]): string | undefined {
40
+ if (args[0] !== "review") return undefined;
41
+ const verdict = args[2];
42
+ if (verdict !== "changes_requested") return undefined;
43
+ const note = args.slice(3).filter(word => !word.startsWith("--")).join(" ").trim();
44
+ return note ? undefined : "say what to fix before requesting changes";
45
+ }
46
+
47
+ /** The notice after a review command finished: `#372 approved`, `#372 changes requested`, or the CLI's own refusal verbatim (#711 change 3). */
48
+ export function reviewResult(args: readonly string[], code: number, diagnostic: string): { text: string; tone: "success" | "failure" } | undefined {
49
+ if (args[0] !== "review" || !/^\d+$/.test(args[1] ?? "")) return undefined;
50
+ const verdict = args[2];
51
+ if (verdict !== "approved" && verdict !== "changes_requested") return undefined;
52
+ if (code) return { text: clean(diagnostic) || `command exited ${code}`, tone: "failure" };
53
+ return { text: `#${args[1]} ${verdict === "approved" ? "approved" : "changes requested"}`, tone: "success" };
54
+ }
@@ -7,6 +7,9 @@ import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
9
9
  import type { SettingRow, SettingValue } from "../hub/settings.ts";
10
+ import { symbolFields, overlapFields } from "./console-symbols.ts";
11
+ import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
12
+ import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
10
13
 
11
14
  export interface Approval {
12
15
  id: string; peer: string; title: string; expiresAt: number;
@@ -45,10 +48,16 @@ export interface ConsoleState {
45
48
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
46
49
  peerFilter?: string; kindFilter?: string; project?: string;
47
50
  taskSelection?: number; queueSelection?: string;
51
+ /** #683 U3: the Usage panel (7): what the `usage` op last answered, and the task that was selected in Tasks when the panel was opened. */
52
+ usage?: UsageState; usageTask?: number;
48
53
  actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
49
54
  confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string; lock?: ConsoleState["actionLock"] } | { type: "permission_default"; peer: string; source: string };
50
55
  optionChoice?: string;
51
56
  modeChoice?: string;
57
+ /** #711: `r` on a task in review opens this choice (a approve, c request changes) the way `m` opens the permission-mode choice. */
58
+ reviewChoice?: ReviewChoice;
59
+ /** The guide line above the input after a verdict was chosen; it goes with the input line. */
60
+ reviewGuide?: string;
52
61
  permissionDefaults: PermissionDefault[];
53
62
  permissionDefaultsHandled: string[];
54
63
  }
@@ -84,7 +93,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
84
93
  s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
85
94
  const pending = s.confirm;
86
95
  if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
87
- if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
96
+ if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.reviewChoice || s.optionChoice || s.help) return false;
88
97
  const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
89
98
  if (!next) return false;
90
99
  s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
@@ -310,8 +319,19 @@ function fieldLines(fields: [string, unknown][], columns: number, now: number):
310
319
  });
311
320
  });
312
321
  }
313
- const detailLines = (detail: Detail, columns: number, now: number): Span[][] =>
314
- typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(Object.entries(detail), columns, now).flat();
322
+ /**
323
+ * A task opened from Tasks also gets its "changed symbols" (#684 2b), after its plan so the two read together; a private task gets none. `codeSymbols` is the hub's
324
+ * raw observation and `symbolOverlaps` is the hub pair projection; formatted blocks replace both raw fields.
325
+ */
326
+ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
327
+ const fields = Object.entries(detail).filter(([key]) => key !== "codeSymbols" && key !== "symbolOverlaps");
328
+ const block = [...symbolFields(detail), ...overlapFields(detail)];
329
+ if (!block.length) return fields;
330
+ const at = fields.findIndex(([key]) => key === "plan");
331
+ return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
332
+ }
333
+ const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] =>
334
+ typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(task ? taskFields(detail) : Object.entries(detail), columns, now).flat();
315
335
  const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
316
336
  /** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
317
337
  export function approvalFrom(msg: any): Approval {
@@ -341,10 +361,11 @@ const KEYS: [string, string[]][] = [
341
361
  ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Home/< first, End/> last", "Enter view", "j/k scroll a view", "w web UI (outside Peers)", "o pet window"]],
342
362
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
343
363
  ["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)"]],
344
- ["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r review (in review)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
345
- ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
364
+ ["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r approve or request changes (in review; a or c, then a note)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
365
+ ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry", "D discard the peer's whole queued backlog (reason, then y)"]],
346
366
  ["Events", ["f peer filter", "g kind filter"]],
347
367
  ["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
368
+ ["Usage", ["7 Usage panel", "Enter view a peer", "tokens today/7d/lifetime with sources", "task from Tasks (3)"]],
348
369
  ];
349
370
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
350
371
  export function keyTable(columns: number, attention = false, characters = false): Span[][] {
@@ -395,7 +416,7 @@ export function parseConsoleCommand(input: string): string[] {
395
416
  }
396
417
  export function commandNeedsConfirmation(args: string[]): boolean {
397
418
  const positional = args.filter(arg => arg !== "--yes");
398
- return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
419
+ return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && (args[1] === "resolve" || args[1] === "discard")) || (args[0] === "permit" && args[2] !== "deny");
399
420
  }
400
421
  export function panelRows(s: ConsoleState): any[] {
401
422
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -406,6 +427,7 @@ export function panelRows(s: ConsoleState): any[] {
406
427
  return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
407
428
  }
408
429
  if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
430
+ if (s.panel === 7) return usagePeers(s.usage, s.budget, s.peers);
409
431
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
410
432
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
411
433
  }
@@ -462,7 +484,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
462
484
  return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
463
485
  }
464
486
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
465
- const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
487
+ const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.reviewChoice || state.help || state.editing || state.input);
466
488
  // A notice answers the key before this one; one that pruning raises now stays.
467
489
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
468
490
  const done = () => ({ state: bound(s), effects });
@@ -533,19 +555,47 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
533
555
  }
534
556
  return done();
535
557
  }
558
+ if (s.reviewChoice) {
559
+ const choice = s.reviewChoice;
560
+ if (key === "\x1b") { s.reviewChoice = undefined; return done(); }
561
+ const verdict: Verdict | undefined = key === "a" ? "approved" : key === "c" ? "changes_requested" : undefined;
562
+ if (verdict) {
563
+ s.reviewChoice = undefined;
564
+ // The task may have left review while the choice was open: say so instead of prefilling a command the hub will refuse.
565
+ const current = s.tasks.find(task => task.id === choice.id);
566
+ if (current && current.state !== "in_review") notify(s, `#${choice.id} is no longer in review (${current.state})`, now);
567
+ else { s.input = verdictInput(choice.id, verdict); s.reviewGuide = verdictGuide(choice.id, verdict); s.editing = true; }
568
+ }
569
+ return done(); // any other key leaves the choice open
570
+ }
536
571
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
537
572
  if (s.editing || s.input) {
538
- if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
573
+ if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined; }
539
574
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
540
575
  else if (key === "\x15") s.input = "";
541
576
  else if (key === "\x1b[A" || key === "\x1b[B") {
542
577
  s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
543
578
  s.input = s.history[s.historyIndex] ?? "";
544
579
  } else if (key === "\r" || key === "\n") {
545
- const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
580
+ const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined;
546
581
  if (!input.trim()) return done();
547
582
  try {
548
583
  const parsed = parseConsoleCommand(input);
584
+ if (parsed[0] === "queue" && parsed[1] === "discard") {
585
+ const reasonAt = parsed.indexOf("--reason"), peer = commandFlag(parsed, "--peer"), state = commandFlag(parsed, "--state") ?? "queued";
586
+ // The reason is every word after --reason up to the next flag, so a reason typed without quotes ("agy backlog") is whole, not cut to its first word.
587
+ const words: string[] = [];
588
+ for (let i = reasonAt + 1; reasonAt >= 0 && i < parsed.length && !parsed[i]!.startsWith("--"); i++) words.push(parsed[i]!);
589
+ const reason = words.join(" ").trim();
590
+ if (!peer || !reason) throw new Error("queue discard needs --peer and --reason (it is recorded on every discarded delivery)");
591
+ const count = s.queue.filter(row => row.peer === peer && row.state === state).length;
592
+ const args = ["queue", "discard", "--peer", peer, ...(parsed.includes("--state") ? ["--state", state] : []), "--reason", reason, "--yes"];
593
+ s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
594
+ s.confirm = { type: "command", args, prompt: `discard all ${count} ${state} deliver${count === 1 ? "y" : "ies"} of ${peer}; no task or native turn is cancelled, other peers are untouched` };
595
+ return done();
596
+ }
597
+ const reviewProblem = reviewRefusal(parsed); // an empty changes note never reaches the hub (#711)
598
+ if (reviewProblem) throw new Error(reviewProblem);
549
599
  const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
550
600
  if (args[0] === "queue" && args[1] === "resolve") {
551
601
  const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
@@ -614,11 +664,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
614
664
  }
615
665
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
616
666
  if (key === ":") { s.editing = true; return done(); }
617
- const switchPanel = (panel: number) => { s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
618
- if (s.mode === "panels" && /^[1-6]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
667
+ const switchPanel = (panel: number) => {
668
+ // The task selected in Tasks is the one the Usage panel shows; switchPanel clears that selection, so it is read first (#683 U3).
669
+ // With no valid selected row (an empty Tasks panel) the old task is cleared, never kept: the panel must not show a task that is no longer the selection.
670
+ if (panel === 7 && s.panel === 3) { const task = selectedPanelRow(s); s.usageTask = task && Number.isSafeInteger(Number(task.id)) && Number(task.id) > 0 ? Number(task.id) : undefined; }
671
+ s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
672
+ if (s.mode === "panels" && /^[1-7]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
619
673
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
620
674
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
621
- if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel === 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
675
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel >= 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
622
676
  if (s.mode === "panels" && s.panel === 1 && key === "w") {
623
677
  const peer = selectedPanelRow(s)?.id;
624
678
  if (!peer || s.detail !== undefined) { notify(s, "select a peer row to edit its planner WIP limit", now); return done(); }
@@ -702,6 +756,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
702
756
  if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
703
757
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
704
758
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
759
+ else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
705
760
  else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
706
761
  return done();
707
762
  }
@@ -732,8 +787,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
732
787
  else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
733
788
  return done();
734
789
  }
735
- const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
736
- s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
790
+ // #711: `r` on a task in review asks which verdict first, from the row or from the open detail, so the person is never left to guess the next word.
791
+ if (s.panel === 3 && key === "r" && selected.state === "in_review") {
792
+ const choice = reviewChoiceFor({ ...selected, reviewer: selected.reviewer ?? s.tasks.find(task => task.id === item)?.reviewer, signals: selected.signals ?? s.tasks.find(task => task.id === item)?.signals });
793
+ if (choice) s.reviewChoice = choice;
794
+ return done();
795
+ }
796
+ const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` : undefined;
737
797
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
738
798
  if (s.panel === 3 && key === "c") {
739
799
  if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
@@ -754,6 +814,12 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
754
814
  s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
755
815
  s.editing = true; return done();
756
816
  }
817
+ // #713: D clears the whole queued backlog of the selected row's peer. Nothing runs until a reason is typed and y confirms the count.
818
+ if (s.panel === 4 && key === "D") {
819
+ const peer = String(selected.peer ?? "");
820
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) return done();
821
+ s.input = `queue discard --peer ${peer} --reason `; s.editing = true; return done();
822
+ }
757
823
  if (s.panel === 4 && key === "r") {
758
824
  if (!["queued", "needs_review"].includes(selected.state)) return done();
759
825
  const revision = selected.revision;
@@ -787,6 +853,7 @@ export const TABLES: Record<number, string[]> = {
787
853
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
788
854
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
789
855
  6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
856
+ 7: USAGE_HEAD,
790
857
  };
791
858
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
792
859
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
@@ -810,6 +877,10 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
810
877
  span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
811
878
  span(row.servedBy ?? row.requestedModel ?? "-")];
812
879
  }
880
+ if (s.panel === 7) {
881
+ const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
882
+ return text.map((value, i) => i === 0 ? id(value, "info") : span(value, value === "-" || value === "unknown" || value === "no records" ? "muted" : i === 4 ? "attention" : undefined));
883
+ }
813
884
  if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
814
885
  if (s.panel === 3 && s.backlogView) {
815
886
  const waits = waitingDeps(s, row);
@@ -884,7 +955,7 @@ function taskHints(row: any): string[] {
884
955
  const review = row.state === "in_review";
885
956
  const hints = [review ? "a reviewer" : "a assign"];
886
957
  if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
887
- if (review) hints.push("r review");
958
+ if (review) hints.push(reviewHint(row.reviewer));
888
959
  if (["proposed", "in_progress"].includes(row.state)) hints.push("R return");
889
960
  return hints;
890
961
  }
@@ -894,7 +965,7 @@ function panelContext(s: ConsoleState, row: any): string[] {
894
965
  if (!row) return [];
895
966
  if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
896
967
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
897
- if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
968
+ if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
898
969
  return [];
899
970
  }
900
971
  /** The footer's key hint: only keys that act in this mode, panel and state. */
@@ -903,7 +974,9 @@ function hint(s: ConsoleState, columns: number): string {
903
974
  if (s.mode === "stream") return [...pending, ...(pending.length ? ["v view"] : []), ...(s.approvals.length > (pending.length ? 1 : 0) ? ["[ ] select"] : []), "Tab panels", ": command", "? keys", "q quit"].join(" ");
904
975
  if (s.help) return "? or Esc close q quit";
905
976
  const approvals = s.panel === 2 ? pending : [];
906
- if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
977
+ // An open task detail names the review action too (#711): the detail is where the person reads the task, so it is where they decide.
978
+ const shown = s.panel === 3 && s.detail && typeof s.detail === "object" && (s.detail as { state?: unknown }).state === "in_review" ? reviewHint((s.detail as { reviewer?: unknown }).reviewer) : undefined;
979
+ if (s.detail !== undefined || s.requestDetail) return [...approvals, ...(shown ? [shown] : []), "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
907
980
  const rows = panelRows(s); const row = selectedPanelRow(s);
908
981
  const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
909
982
  if (s.panel === 3 && Bun.stringWidth(text) > columns) {
@@ -918,14 +991,22 @@ function hint(s: ConsoleState, columns: number): string {
918
991
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
919
992
  function header(s: ConsoleState, columns: number): Span[] {
920
993
  const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
921
- const tabs: Span[] = [];
922
- for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings"].entries()) {
923
- const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
924
- tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
925
- if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
926
- tabs.push(span(active ? "]" : " ", tone));
927
- }
928
- return fitLine([span(fit(s.project ?? "agent-hub", Math.max(0, columns - Bun.stringWidth(tabs.map(t => t.text).join("")))), "info"), ...tabs], columns);
994
+ const project = s.project ?? "agent-hub";
995
+ // Seven tabs fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
996
+ const build = (lead: string) => {
997
+ const tabs: Span[] = [];
998
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage"].entries()) {
999
+ const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
1000
+ tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${i + 1} ${name}`, tone));
1001
+ if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
1002
+ tabs.push(span(active ? "]" : " ", tone));
1003
+ }
1004
+ return tabs;
1005
+ };
1006
+ const width = (tabs: Span[]) => Bun.stringWidth(tabs.map(t => t.text).join(""));
1007
+ let tabs = build(" ");
1008
+ if (columns - width(tabs) < Math.min(Bun.stringWidth(project), 8)) tabs = build(" ");
1009
+ return fitLine([span(fit(project, Math.max(0, columns - width(tabs))), "info"), ...tabs], columns);
929
1010
  }
930
1011
  /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
931
1012
  const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
@@ -968,6 +1049,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
968
1049
  prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
969
1050
  }
970
1051
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
1052
+ if (s.reviewChoice) prompt = choicePrompt(s.reviewChoice);
971
1053
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
972
1054
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
973
1055
  const summary: Span[] = [];
@@ -994,7 +1076,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
994
1076
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
995
1077
  if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
996
1078
  const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
997
- fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
1079
+ fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : s.editing && s.reviewGuide ? [span(s.reviewGuide, "info")] : approvals, columns),
998
1080
  fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
999
1081
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
1000
1082
  if (s.mode === "stream") {
@@ -1020,7 +1102,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1020
1102
  }
1021
1103
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
1022
1104
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
1023
- const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
1105
+ const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
1106
+ const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
1107
+ : s.detail !== undefined ? detailLines(s.detail, columns, now, s.panel === 3) : fieldLines(approvalFields(permission!), columns, now).flat();
1024
1108
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
1025
1109
  }
1026
1110
  else {
@@ -1031,10 +1115,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1031
1115
  const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
1032
1116
  if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
1033
1117
  const tail = [...allow, ...deny];
1034
- const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
1118
+ // The Usage panel's selected-task lines are reserved before the peer table is sized, so many peers can never push them off a short terminal (#683 U3).
1119
+ // At most half the body goes to them; a longer list is cut with a count. The status line and the table header take the other two rows.
1120
+ let taskLines = s.panel === 7 ? usageTaskLines(s.usage, s.usageTask) : [];
1121
+ const taskRoom = Math.max(1, Math.floor(height / 2));
1122
+ if (taskLines.length > taskRoom) taskLines = [...taskLines.slice(0, taskRoom - 1), { text: ` ... ${taskLines.length - taskRoom + 1} more`, tone: "muted" as const }];
1123
+ const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : s.panel === 7 ? height - 2 - taskLines.length : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
1035
1124
  const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
1036
1125
  const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
1037
- const rowsOf = head ? s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
1126
+ const rowsOf = head ? s.panel === 7 ? optionalTable(head, cellRows, columns, USAGE_DROP) : s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
1038
1127
  if (s.panel === 6) {
1039
1128
  const row = selectedPanelRow(s);
1040
1129
  const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
@@ -1045,6 +1134,11 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1045
1134
  for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
1046
1135
  }
1047
1136
  }
1137
+ if (s.panel === 7) {
1138
+ const note = s.usage?.problem ? `usage unavailable: ${s.usage.problem}` : !s.usage?.life ? "usage loading..." : s.usage.behind ? "usage still catching up with the event log: numbers are partial and refresh in a few seconds"
1139
+ : `usage through ${s.usage.at ? relative(s.usage.at, now) : "now"}: today is the UTC day, 7d the last seven UTC days`;
1140
+ lines.push(fitLine([span(note, s.usage?.problem ? "failure" : !s.usage?.life || s.usage.behind ? "attention" : "info")], columns));
1141
+ }
1048
1142
  if (s.panel === 3 && s.backlogView) {
1049
1143
  lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
1050
1144
  } else if (s.panel === 3) {
@@ -1065,6 +1159,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1065
1159
  lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
1066
1160
  }
1067
1161
  if (!data.length) lines.push([span(" (empty)")]);
1162
+ if (s.panel === 7) for (const line of taskLines) lines.push(fitLine([span(" " + line.text, line.tone)], columns));
1068
1163
  if (title.length) {
1069
1164
  const room = Math.max(1, height - lines.length - 1 - tail.length);
1070
1165
  if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }