@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.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/adapters/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -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
|
-
|
|
314
|
-
|
|
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
|
|
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) => {
|
|
618
|
-
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
tabs
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
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
|
-
|
|
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]!); }
|