@staix/agent-hub 0.20.0 → 0.22.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 +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
4
|
+
import { loadConfig, type HubConfig } from "../hub/daemon.ts";
|
|
5
|
+
import { captureRecoveryPolicy, readRecoveryPolicyInputs, type RecoveryPolicyInputs } from "../hub/recovery-policy.ts";
|
|
6
|
+
import { authorizeConductorRecovery, assertConductorRecovery, retireConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "../hub/recovery-authority.ts";
|
|
7
|
+
import { operationPath } from "../hub/recovery-store.ts";
|
|
8
|
+
import { readRestartSnapshot } from "../hub/restart.ts";
|
|
9
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
10
|
+
import { packageDigest, recoveryTargetDigest } from "./recovery-package.ts";
|
|
11
|
+
import type { Project } from "../hub/registry.ts";
|
|
12
|
+
import { planFingerprint, registeredProjects, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
|
|
13
|
+
import { realPath } from "../hub/project.ts";
|
|
14
|
+
|
|
15
|
+
function unavailable(): never { throw new RecoveryAuthorityError(); }
|
|
16
|
+
function validateConductorOperation(op: RecoveryOperation): void {
|
|
17
|
+
try {
|
|
18
|
+
const { fingerprint, ...body } = op.plan;
|
|
19
|
+
if (op.schema !== 1 || !["restart", "upgrade"].includes(op.plan.kind) || planFingerprint(body) !== fingerprint || !op.plan.projects.length
|
|
20
|
+
|| op.plan.projects.length > 128 || new Set(op.plan.projects.map(row => row.project.id)).size !== op.plan.projects.length) unavailable();
|
|
21
|
+
} catch { unavailable(); }
|
|
22
|
+
}
|
|
23
|
+
export function conductorRecoveryBinding(op: RecoveryOperation, project: Pick<Project, "root" | "id" | "stateDir">, peer: string,
|
|
24
|
+
captured?: { config: HubConfig; inputs: RecoveryPolicyInputs }): ConductorRecoveryBinding {
|
|
25
|
+
validateConductorOperation(op);
|
|
26
|
+
const planned = op.plan.projects.find(row => row.project.id === project.id);
|
|
27
|
+
if (!planned?.source.instanceId || op.plan.projects.some(row => row.terminals.length > 0) || !op.targetRoot || !op.targetDigest || !op.targetFeatures?.includes("conductor_recovery") || op.disposition || op.audit?.length || op.freshOverride) unavailable();
|
|
28
|
+
const inputs = captured?.inputs ?? readRecoveryPolicyInputs(project.root, op.conductorRoutingDefaults?.[project.id]);
|
|
29
|
+
const candidate = captured?.config ?? loadConfig(project.root, inputs.layers, inputs.routing.retired ?? [], []);
|
|
30
|
+
const policy = captureRecoveryPolicy(project, peer, candidate, inputs);
|
|
31
|
+
return { schema: 1, operationId: op.id, peer, kind: op.plan.kind, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
|
|
32
|
+
sourceInstanceId: planned.source.instanceId, targetRoot: op.targetRoot, targetDigest: op.targetDigest, planFingerprint: op.plan.fingerprint, ...policy };
|
|
33
|
+
}
|
|
34
|
+
export async function conductorRecoveryRequest(project: Project, peer: string, message: Record<string, unknown>): Promise<any> {
|
|
35
|
+
let client: ControlClient | undefined;
|
|
36
|
+
try {
|
|
37
|
+
const descriptor = readControl(project.stateDir);
|
|
38
|
+
if (!descriptor?.instanceId || !descriptor.features?.includes("conductor_recovery")) unavailable();
|
|
39
|
+
client = await ControlClient.connect(project.stateDir, { role: "tools", peer, projectId: project.id, projectRoot: project.root, instanceId: descriptor.instanceId });
|
|
40
|
+
if (!client.features.includes("conductor_recovery")) unavailable();
|
|
41
|
+
const result = await client.request(message, 30_000);
|
|
42
|
+
if (result.ok !== true) unavailable();
|
|
43
|
+
return result;
|
|
44
|
+
} catch { unavailable(); }
|
|
45
|
+
finally { client?.close(); }
|
|
46
|
+
}
|
|
47
|
+
/** No hold, lock, staging or package effect precedes proof in every affected project. */
|
|
48
|
+
export async function preflightConductorRecovery(plan: Pick<UpgradePlan, "projects" | "kind">, peer: string): Promise<Record<string, string>> {
|
|
49
|
+
const defaults: Record<string, string> = {};
|
|
50
|
+
if (!Array.isArray(plan.projects) || !plan.projects.length || plan.projects.some(row => !Array.isArray(row.terminals) || row.terminals.length > 0)) unavailable();
|
|
51
|
+
for (const row of plan.projects) {
|
|
52
|
+
const result = await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "authorize", command: plan.kind });
|
|
53
|
+
if (row.source.instanceId && result.instanceId !== row.source.instanceId) unavailable();
|
|
54
|
+
if (result.routingDefault !== undefined) {
|
|
55
|
+
if (typeof result.routingDefault !== "string" || Buffer.byteLength(result.routingDefault) > 1024 * 1024) unavailable();
|
|
56
|
+
defaults[row.project.id] = result.routingDefault;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return defaults;
|
|
60
|
+
}
|
|
61
|
+
/** This is called only before the new public receipt exists; resume can never mint a grant. */
|
|
62
|
+
export async function issueConductorOperation(op: RecoveryOperation, peer: string): Promise<void> {
|
|
63
|
+
if (existsSync(operationPath(op.id))) unavailable();
|
|
64
|
+
await preflightConductorRecovery(op.plan, peer);
|
|
65
|
+
for (const row of op.plan.projects) {
|
|
66
|
+
const binding = conductorRecoveryBinding(op, row.project, peer);
|
|
67
|
+
await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "issue", binding, plan: op.plan });
|
|
68
|
+
}
|
|
69
|
+
// A positive transport reply is not a source-issued grant. Prove every durable record before the public lock.
|
|
70
|
+
await authorizeConductorOperation(op, peer);
|
|
71
|
+
}
|
|
72
|
+
function localAuthority(op: RecoveryOperation, project: Project, peer: string) {
|
|
73
|
+
try {
|
|
74
|
+
const expected = conductorRecoveryBinding(op, project, peer);
|
|
75
|
+
if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest || recoveryTargetDigest(op.plan.kind, expected.targetRoot) !== expected.targetDigest) unavailable();
|
|
76
|
+
return authorizeConductorRecovery(project.stateDir, expected, () => {
|
|
77
|
+
try { return JSON.stringify(conductorRecoveryBinding(op, project, peer)) === JSON.stringify(expected); } catch { return false; }
|
|
78
|
+
});
|
|
79
|
+
} catch { unavailable(); }
|
|
80
|
+
}
|
|
81
|
+
/** Synchronous fence for the already authenticated private bootstrap's local effect boundaries. */
|
|
82
|
+
export function assertConductorOperationLocal(op: RecoveryOperation, peer: string): void {
|
|
83
|
+
validateConductorOperation(op);
|
|
84
|
+
for (const row of op.plan.projects) assertConductorRecovery(localAuthority(op, row.project, peer));
|
|
85
|
+
}
|
|
86
|
+
export async function authorizeConductorOperation(op: RecoveryOperation, peer: string): Promise<void> {
|
|
87
|
+
validateConductorOperation(op);
|
|
88
|
+
const proofs = [];
|
|
89
|
+
for (const row of op.plan.projects) {
|
|
90
|
+
const proof = localAuthority(op, row.project, peer);
|
|
91
|
+
proofs.push(proof);
|
|
92
|
+
const descriptor = readControl(row.project.stateDir);
|
|
93
|
+
if (descriptor) {
|
|
94
|
+
if (!descriptor.pid || !descriptor.pidSignature) unavailable();
|
|
95
|
+
const live = processLiveness(descriptor.pid, descriptor.pidSignature);
|
|
96
|
+
if (live !== "gone") {
|
|
97
|
+
if (live !== "live") unavailable();
|
|
98
|
+
await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "verify", binding: proof.binding });
|
|
99
|
+
} else requireConductorCheckpoint(op, row.project);
|
|
100
|
+
} else requireConductorCheckpoint(op, row.project);
|
|
101
|
+
assertConductorRecovery(proof);
|
|
102
|
+
}
|
|
103
|
+
// A can change while B answers: every project is checked again after the last await.
|
|
104
|
+
for (const proof of proofs) assertConductorRecovery(proof);
|
|
105
|
+
}
|
|
106
|
+
export async function requireOwnedConductorTarget(op: RecoveryOperation, project: Project, peer: string): Promise<void> {
|
|
107
|
+
const binding = conductorRecoveryBinding(op, project, peer);
|
|
108
|
+
const result = await conductorRecoveryRequest(project, peer, { t: "recovery_authority", op: "verify", binding });
|
|
109
|
+
if (result.target !== true || result.operationId !== op.id || !["restored", "released"].includes(result.phase)) unavailable();
|
|
110
|
+
}
|
|
111
|
+
export function requireConductorCheckpoint(op: RecoveryOperation, project: Project): void {
|
|
112
|
+
try {
|
|
113
|
+
const snapshot = readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id, operationId: op.id });
|
|
114
|
+
if (!snapshot || snapshot.operationId !== op.id || snapshot.sourceInstanceId !== op.plan.projects.find(row => row.project.id === project.id)?.source.instanceId) unavailable();
|
|
115
|
+
} catch { unavailable(); }
|
|
116
|
+
}
|
|
117
|
+
export function retireConductorOperation(op: RecoveryOperation, peer: string): void {
|
|
118
|
+
if (op.phase !== "completed" && op.phase !== "cancelled") unavailable();
|
|
119
|
+
for (const row of op.plan.projects) retireConductorRecovery(localAuthority(op, row.project, peer));
|
|
120
|
+
}
|
|
121
|
+
/** Private launcher continuation, only the reviewed target and a captured effect, never arbitrary console argv. */
|
|
122
|
+
export async function authorizeConductorLaunch(op: RecoveryOperation, peer: string, root: string, targetRoot: string, command: string, args: readonly string[]): Promise<void> {
|
|
123
|
+
if (!["pending", "running", "blocked"].includes(op.phase)) unavailable();
|
|
124
|
+
await authorizeConductorOperation(op, peer);
|
|
125
|
+
if (!op.targetRoot || realPath(targetRoot) !== realPath(op.targetRoot)) unavailable();
|
|
126
|
+
const planned = op.plan.projects.find(row => row.project.root === root);
|
|
127
|
+
if (!planned) unavailable();
|
|
128
|
+
if ((command === "up" || command === "daemon") && args.length === 0) {
|
|
129
|
+
const descriptor = readControl(planned.project.stateDir);
|
|
130
|
+
if (descriptor && processLiveness(descriptor.pid, descriptor.pidSignature) !== "gone") unavailable();
|
|
131
|
+
const claim = registeredProjects().find(row => row.id === planned.project.id);
|
|
132
|
+
if (!claim || claim.root !== root || claim.stateDir !== planned.project.stateDir
|
|
133
|
+
|| claim.pid !== null && processLiveness(claim.pid, claim.pidSignature) !== "gone"
|
|
134
|
+
|| claim.pid === null && claim.instanceId !== null) unavailable();
|
|
135
|
+
requireConductorCheckpoint(op, planned.project); return;
|
|
136
|
+
}
|
|
137
|
+
if (command === "setup" && op.plan.kind === "upgrade" && args.length === 1 && args[0] === "--yes") {
|
|
138
|
+
for (const row of op.plan.projects) await requireOwnedConductorTarget(op, row.project, peer);
|
|
139
|
+
await authorizeConductorOperation(op, peer); return;
|
|
140
|
+
}
|
|
141
|
+
unavailable();
|
|
142
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
|
+
import { externalInstanceCatalog, type ExternalInstanceCatalog } from "../adapters/external-metadata.ts";
|
|
3
|
+
import type { DeclaredExternalInstance } from "../hub/instance-config.ts";
|
|
2
4
|
import type { InstancesConfig } from "../hub/instances.ts";
|
|
3
5
|
import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
|
|
4
6
|
import { configuredAcpPreview } from "./preview.ts";
|
|
5
7
|
|
|
6
8
|
export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
|
|
7
|
-
export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
|
|
9
|
+
export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig, declaredExternalInstances: readonly DeclaredExternalInstance[] = []): ConfiguredAcpSelection {
|
|
8
10
|
const definition = catalog.definitionOf(kind);
|
|
9
|
-
if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
|
|
11
|
+
if (!definition?.permission.genericAcp && !declaredExternalInstances.some(row => row.kind === kind)) throw new Error("unknown configured ACP kind");
|
|
10
12
|
const values = new Map<string, string>();
|
|
11
13
|
let preview = false;
|
|
12
14
|
for (let index = 0; index < raw.length; index++) {
|
|
@@ -20,21 +22,35 @@ export function parseConfiguredAcpArguments(kind: string, raw: readonly string[]
|
|
|
20
22
|
}
|
|
21
23
|
if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
|
|
22
24
|
const peer = values.get("--as") ?? kind;
|
|
23
|
-
|
|
25
|
+
const executable = !!instances && Object.hasOwn(instances, peer) && instances[peer]?.kind === kind && !!catalog.definitionOf(peer, instances);
|
|
26
|
+
const declaredOnly = declaredExternalInstances.some(row => row.id === peer && row.kind === kind && row.availability === "declared-unknown" && row.refusalCode === "catalog-unavailable");
|
|
27
|
+
// Inert declarations select a preview or request id only; live daemon admission remains mandatory.
|
|
28
|
+
if (values.has("--as") && !executable && !declaredOnly) throw new Error("--as must name a declared instance of this configured kind");
|
|
24
29
|
return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
|
|
25
30
|
}
|
|
26
31
|
export interface ConfiguredAcpControl {
|
|
27
32
|
features: readonly string[];
|
|
33
|
+
readonly instanceId?: string;
|
|
34
|
+
readonly isOpen?: boolean;
|
|
35
|
+
readExternalInstanceCatalog?(projectRoot: string): Promise<ExternalInstanceCatalog>;
|
|
28
36
|
request(message: Record<string, unknown>, timeout?: number): Promise<any>;
|
|
29
37
|
close(): void;
|
|
30
38
|
}
|
|
31
|
-
export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
|
|
39
|
+
export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void, external?: { projectRoot: string }): Promise<void> {
|
|
32
40
|
if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
|
|
33
41
|
if (peer !== kind) requirePeerInstances(hub.features);
|
|
42
|
+
const epoch = hub.instanceId;
|
|
43
|
+
if (external && (!epoch || hub.isOpen !== true || !hub.readExternalInstanceCatalog || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => hub.features.includes(feature)))) throw new Error("running hub lacks a live external instance catalog");
|
|
34
44
|
const reply = await hub.request({ t: "status" });
|
|
35
45
|
const active = reply.status?.configuredAdapters;
|
|
36
46
|
if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
|
|
37
47
|
if (peer !== kind) requireActiveInstance(reply.status, peer);
|
|
48
|
+
if (external) {
|
|
49
|
+
const observed = externalInstanceCatalog(await hub.readExternalInstanceCatalog!(external.projectRoot));
|
|
50
|
+
const row = observed.instances.find(row => row.id === peer);
|
|
51
|
+
if (hub.isOpen !== true || hub.instanceId !== epoch || reply.status?.instanceId !== epoch || reply.status?.cwd !== external.projectRoot
|
|
52
|
+
|| observed.epoch !== epoch || observed.projectRoot !== external.projectRoot || !row || row.kind !== kind || !["ready", "offline"].includes(row.availability)) throw new Error("external instance is not bound to the current live catalog");
|
|
53
|
+
}
|
|
38
54
|
const namespace = `adapters.${kind}`;
|
|
39
55
|
const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
|
|
40
56
|
const key = row && typeof row === "object" ? row.key : undefined;
|
|
@@ -44,13 +60,17 @@ export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind
|
|
|
44
60
|
}
|
|
45
61
|
export async function runConfiguredAcpEntry(context: {
|
|
46
62
|
kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
|
|
63
|
+
declaredExternalInstances?: readonly DeclaredExternalInstance[]; projectRoot?: string;
|
|
47
64
|
connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
|
|
48
65
|
}): Promise<void> {
|
|
49
|
-
const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
|
|
66
|
+
const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances, context.declaredExternalInstances);
|
|
50
67
|
if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
|
|
51
68
|
const hub = await context.connect();
|
|
52
69
|
try {
|
|
53
|
-
|
|
70
|
+
const external = selected.peer !== context.kind && (context.declaredExternalInstances?.some(row => row.id === selected.peer && row.kind === context.kind)
|
|
71
|
+
|| context.catalog.externalDefinitions.some(definition => definition.kind === context.kind));
|
|
72
|
+
if (external && !context.projectRoot) throw new Error("external instance start requires the canonical project root");
|
|
73
|
+
await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print, external ? { projectRoot: context.projectRoot! } : undefined);
|
|
54
74
|
const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
|
|
55
75
|
if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
|
|
56
76
|
context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
|
package/src/cli/console-state.ts
CHANGED
|
@@ -6,12 +6,15 @@ import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCata
|
|
|
6
6
|
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
|
+
import { DOING_STUB } from "../hub/peer-doing.ts";
|
|
9
10
|
import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
10
11
|
import { symbolFields, overlapFields } from "./console-symbols.ts";
|
|
11
12
|
import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
|
|
12
13
|
import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
|
|
13
14
|
import { windowKind } from "../hub/window-kind.ts";
|
|
15
|
+
import { ownerIsolationText, taskHolderText } from "./role-holders.ts";
|
|
14
16
|
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
17
|
+
import type { RoomStatusView } from "../rooms/status.ts";
|
|
15
18
|
|
|
16
19
|
export interface Approval {
|
|
17
20
|
id: string; peer: string; title: string; expiresAt: number;
|
|
@@ -55,6 +58,8 @@ export interface ConsoleState {
|
|
|
55
58
|
taskSelection?: number; queueSelection?: string;
|
|
56
59
|
/** #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. */
|
|
57
60
|
usage?: UsageState; usageTask?: number;
|
|
61
|
+
/** Absent on older hubs, null when the current hub cannot report room status. */
|
|
62
|
+
rooms?: RoomStatusView[] | null; roomSelection?: string;
|
|
58
63
|
actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
|
|
59
64
|
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 };
|
|
60
65
|
optionChoice?: string;
|
|
@@ -66,6 +71,7 @@ export interface ConsoleState {
|
|
|
66
71
|
permissionDefaults: PermissionDefault[];
|
|
67
72
|
permissionDefaultsHandled: string[];
|
|
68
73
|
}
|
|
74
|
+
const roomsVisible = (s: Pick<ConsoleState, "rooms">) => s.rooms === null || Array.isArray(s.rooms) && s.rooms.length > 0;
|
|
69
75
|
/** A detail view: text as the stream shows it, or labeled fields. */
|
|
70
76
|
export type Detail = string | Record<string, unknown>;
|
|
71
77
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
@@ -330,7 +336,8 @@ function fieldLines(fields: [string, unknown][], columns: number, now: number):
|
|
|
330
336
|
* raw observation and `symbolOverlaps` is the hub pair projection; formatted blocks replace both raw fields.
|
|
331
337
|
*/
|
|
332
338
|
function taskFields(detail: Record<string, unknown>): [string, unknown][] {
|
|
333
|
-
const fields = Object.entries(detail).filter(([key]) =>
|
|
339
|
+
const fields = Object.entries(detail).filter(([key]) => !["codeSymbols", "symbolOverlaps", "plannerReason", "reviewerReason", "ownerReason", "sameFamilyFallback"].includes(key))
|
|
340
|
+
.map(([key, value]): [string, unknown] => [key, key === "planner" || key === "owner" || key === "reviewer" ? taskHolderText(detail, key) : value]);
|
|
334
341
|
const block = [...symbolFields(detail), ...overlapFields(detail)];
|
|
335
342
|
if (!block.length) return fields;
|
|
336
343
|
const at = fields.findIndex(([key]) => key === "plan");
|
|
@@ -353,7 +360,8 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
|
|
|
353
360
|
function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
|
|
354
361
|
const wip = s.taskWip?.[row.id];
|
|
355
362
|
const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
|
|
356
|
-
|
|
363
|
+
const isolation = ownerIsolationText(row.ownerIsolation, row.ownerIsolationNote);
|
|
364
|
+
return { ...row, ...(isolation ? { ownerIsolation: isolation } : {}), ...(split ? { "planner WIP": split } : {}) };
|
|
357
365
|
}
|
|
358
366
|
const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
|
|
359
367
|
if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
|
|
@@ -397,9 +405,9 @@ const KEYS: [string, string[]][] = [
|
|
|
397
405
|
["Usage", ["7 Usage panel", "Enter view a peer", "tokens today/7d/lifetime with sources", "task from Tasks (3)"]],
|
|
398
406
|
];
|
|
399
407
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
400
|
-
export function keyTable(columns: number, attention = false, characters = false): Span[][] {
|
|
408
|
+
export function keyTable(columns: number, attention = false, characters = false, roomsAvailable = false): Span[][] {
|
|
401
409
|
const lines: Span[][] = []; const pad = 12;
|
|
402
|
-
for (const [group, items] of [...KEYS, ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
|
|
410
|
+
for (const [group, items] of [...KEYS, ...(roomsAvailable ? [["Rooms", ["8 Rooms panel (read only)", "j/k move", "Enter details"]] as [string, string[]]] : []), ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
|
|
403
411
|
let text = ""; let head = group;
|
|
404
412
|
const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
|
|
405
413
|
for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
|
|
@@ -449,6 +457,7 @@ export function commandNeedsConfirmation(args: string[]): boolean {
|
|
|
449
457
|
}
|
|
450
458
|
export function panelRows(s: ConsoleState): any[] {
|
|
451
459
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
460
|
+
if (s.panel === 8) return s.rooms ?? [];
|
|
452
461
|
if (s.panel === 2) return s.approvals;
|
|
453
462
|
if (s.panel === 3) {
|
|
454
463
|
if (s.backlogView) return s.tasks.filter(t => t.state === "backlog").sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id);
|
|
@@ -475,6 +484,7 @@ const selection = (s: ConsoleState) => {
|
|
|
475
484
|
if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
|
|
476
485
|
if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
|
|
477
486
|
if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
|
|
487
|
+
if (s.panel === 8 && s.roomSelection !== undefined) return panelRows(s).findIndex(row => row.name === s.roomSelection);
|
|
478
488
|
return s.selection;
|
|
479
489
|
};
|
|
480
490
|
export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
|
|
@@ -482,6 +492,7 @@ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void
|
|
|
482
492
|
const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
|
|
483
493
|
if (s.panel === 3 && row) s.taskSelection = Number(row.id);
|
|
484
494
|
if (s.panel === 4 && row) s.queueSelection = String(row.id);
|
|
495
|
+
if (s.panel === 8 && row) s.roomSelection = String(row.name);
|
|
485
496
|
}
|
|
486
497
|
const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
|
|
487
498
|
function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
|
|
@@ -704,8 +715,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
704
715
|
// The task selected in Tasks is the one the Usage panel shows; switchPanel clears that selection, so it is read first (#683 U3).
|
|
705
716
|
// 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.
|
|
706
717
|
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; }
|
|
707
|
-
s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
718
|
+
s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.roomSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
708
719
|
if (s.mode === "panels" && /^[1-7]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
|
|
720
|
+
if (s.mode === "panels" && key === "8" && !s.optionChoice && roomsVisible(s)) return switchPanel(8);
|
|
709
721
|
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
710
722
|
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
711
723
|
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);
|
|
@@ -801,6 +813,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
801
813
|
if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
|
|
802
814
|
else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
|
|
803
815
|
else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
|
|
816
|
+
else if (s.panel === 8) { s.roomSelection = row.name; s.detail = { roomName: row.name }; s.detailOffset = 0; }
|
|
804
817
|
else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
|
|
805
818
|
return done();
|
|
806
819
|
}
|
|
@@ -892,12 +905,13 @@ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now =
|
|
|
892
905
|
return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
893
906
|
}
|
|
894
907
|
export const TABLES: Record<number, string[]> = {
|
|
895
|
-
1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
908
|
+
1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL", "DOING"],
|
|
896
909
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
897
910
|
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
898
911
|
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
899
912
|
6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
|
|
900
913
|
7: USAGE_HEAD,
|
|
914
|
+
8: ["ROOM", "LINK", "MEMBERS", "REMOTE"],
|
|
901
915
|
};
|
|
902
916
|
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
903
917
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
@@ -906,6 +920,9 @@ const waitingDeps = (s: ConsoleState, row: any): number[] => (row.deps ?? []).fi
|
|
|
906
920
|
/** One span per column of a panel row; zero counters and unknown values read `-`. */
|
|
907
921
|
export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
908
922
|
const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
|
|
923
|
+
if (s.panel === 8) return [id(row.name, "info"), span(row.link ?? "unknown", row.link ? stateTone(row.link) : "muted"),
|
|
924
|
+
span(row.memberCount === null ? "unknown" : String(row.memberCount), row.memberCount === null ? "muted" : undefined),
|
|
925
|
+
span(row.remotePeers === null ? "unknown" : String(row.remotePeers.length), row.remotePeers === null ? "muted" : undefined)];
|
|
909
926
|
if (s.panel === 1) {
|
|
910
927
|
const b = s.budget[row.id];
|
|
911
928
|
const wip = s.taskWip?.[row.id];
|
|
@@ -919,7 +936,8 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
919
936
|
span(wip ? `${wip.wip}/${wip.wipLimit}` : "-", wip && wip.wip >= wip.wipLimit ? "attention" : undefined),
|
|
920
937
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
921
938
|
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(", ") : "-"),
|
|
922
|
-
span(row.servedBy ?? row.requestedModel ?? "-")
|
|
939
|
+
span(row.servedBy ?? row.requestedModel ?? "-"),
|
|
940
|
+
span(typeof row.doing?.text === "string" && row.doing.text ? row.doing.text : "-", row.doing?.text === DOING_STUB ? "muted" : undefined)]; // #878: screened by the hub
|
|
923
941
|
}
|
|
924
942
|
if (s.panel === 7) {
|
|
925
943
|
const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
|
|
@@ -947,6 +965,21 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
947
965
|
// stream. Colour off concatenates to the same bytes.
|
|
948
966
|
return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
|
|
949
967
|
}
|
|
968
|
+
|
|
969
|
+
function roomDetail(row: RoomStatusView): Record<string, unknown> {
|
|
970
|
+
const relays = row.relays;
|
|
971
|
+
let since = "unknown";
|
|
972
|
+
if (relays) try { const date = new Date(relays.since); if (Number.isFinite(date.getTime())) since = date.toISOString(); } catch { /* keep unknown */ }
|
|
973
|
+
const count = (value: number | null | undefined) => typeof value === "number" ? String(value) : "unknown";
|
|
974
|
+
return {
|
|
975
|
+
room: row.name, "room id": row.room, "link state": row.link ?? "unknown", owner: row.owner, "local member": row.member,
|
|
976
|
+
"member count": count(row.memberCount),
|
|
977
|
+
"remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.state})`) : "0",
|
|
978
|
+
"counter scope": `since daemon start: ${since}`,
|
|
979
|
+
"out handoff": count(relays?.outbound), "in admitted": count(relays?.inbound), withheld: count(relays?.withheld),
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
950
983
|
/**
|
|
951
984
|
* A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
|
|
952
985
|
* widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
|
|
@@ -975,10 +1008,10 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
|
|
|
975
1008
|
}));
|
|
976
1009
|
}
|
|
977
1010
|
/** Keep the status/quota columns visible instead of clipping them behind optional peer metadata. */
|
|
978
|
-
function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
|
|
1011
|
+
function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["DOING", "MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
|
|
979
1012
|
const cap = Math.max(24, Math.floor(columns / 3));
|
|
980
1013
|
const widths = head.map((name, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, Bun.stringWidth(flat(row[i]?.text ?? ""))), name.length)));
|
|
981
|
-
const keep = head.map((_, i) => i);
|
|
1014
|
+
const keep = head.map((_, i) => i).filter(i => head[i] !== "DOING" || rows.some(row => row[i]?.text !== "-")); // #878: shown once a peer has one
|
|
982
1015
|
const total = () => keep.reduce((sum, i) => sum + widths[i]!, 0) + 2 * (keep.length - 1);
|
|
983
1016
|
for (const name of drop) {
|
|
984
1017
|
if (total() <= columns - 2) break;
|
|
@@ -1033,16 +1066,17 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
1033
1066
|
const compact = text.replace("m permission mode", "m mode").replace("w WIP limit", "w WIP").replace("j/k move", "j/k").replace(": command", ": cmd");
|
|
1034
1067
|
return Bun.stringWidth(compact) > columns ? compact.replace("Enter view", "Enter") : compact;
|
|
1035
1068
|
}
|
|
1036
|
-
/** The project, then the tabs:
|
|
1069
|
+
/** The project, then the tabs: Rooms appears only when the hub reports room metadata. */
|
|
1037
1070
|
function header(s: ConsoleState, columns: number): Span[] {
|
|
1038
1071
|
const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
|
|
1039
1072
|
const project = s.project ?? "agent-hub";
|
|
1040
|
-
//
|
|
1073
|
+
// The tabs can fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
|
|
1041
1074
|
const build = (lead: string) => {
|
|
1042
1075
|
const tabs: Span[] = [];
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1076
|
+
const names = ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage", ...(roomsVisible(s) ? ["Rooms"] : [])];
|
|
1077
|
+
for (const [i, name] of names.entries()) {
|
|
1078
|
+
const number = i === 7 ? 8 : i + 1, active = number === s.panel, tone = active ? "strong" : "info";
|
|
1079
|
+
tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${number} ${name}`, tone));
|
|
1046
1080
|
if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
|
|
1047
1081
|
tabs.push(span(active ? "]" : " ", tone));
|
|
1048
1082
|
}
|
|
@@ -1152,10 +1186,13 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1152
1186
|
const definition: [string, unknown][] = row ? [["type", row.type], ...(row.min !== undefined || row.max !== undefined ? [["range", `${row.min ?? "-"}..${row.max ?? "-"}`] as [string, unknown]] : []), ["risk", row.risk], ["applies", row.applies]] : [];
|
|
1153
1187
|
lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ...definition, ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
|
|
1154
1188
|
}
|
|
1155
|
-
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
|
|
1189
|
+
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length, roomsVisible(s)));
|
|
1156
1190
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
1157
1191
|
const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
|
|
1158
|
-
const
|
|
1192
|
+
const roomName = s.panel === 8 && s.detail && typeof s.detail === "object" ? (s.detail as { roomName?: unknown }).roomName : undefined;
|
|
1193
|
+
const room = typeof roomName === "string" && Array.isArray(s.rooms) ? s.rooms.find(row => row.name === roomName) : undefined;
|
|
1194
|
+
const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
|
|
1195
|
+
: typeof roomName === "string" ? detailLines(room ? roomDetail(room) : { room: roomName, status: "unavailable" }, columns, now)
|
|
1159
1196
|
: s.detail !== undefined ? detailLines(s.detail, columns, now, s.panel === 3) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
1160
1197
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
1161
1198
|
}
|
|
@@ -1172,7 +1209,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1172
1209
|
let taskLines = s.panel === 7 ? usageTaskLines(s.usage, s.usageTask) : [];
|
|
1173
1210
|
const taskRoom = Math.max(1, Math.floor(height / 2));
|
|
1174
1211
|
if (taskLines.length > taskRoom) taskLines = [...taskLines.slice(0, taskRoom - 1), { text: ` ... ${taskLines.length - taskRoom + 1} more`, tone: "muted" as const }];
|
|
1175
|
-
const
|
|
1212
|
+
const selectedTask = s.panel === 3 ? data[at] : undefined;
|
|
1213
|
+
const holders = selectedTask && (selectedTask.planner !== undefined || selectedTask.plannerReason !== undefined)
|
|
1214
|
+
? fieldLines((["planner", "owner", "reviewer"] as const).map((role): [string, unknown] => [role, taskHolderText(selectedTask, role)]), columns, now).flat() : [];
|
|
1215
|
+
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 + holders.length : 1)); const start = Math.max(0, at - capacity + 1);
|
|
1176
1216
|
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
1177
1217
|
const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
|
|
1178
1218
|
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;
|
|
@@ -1191,6 +1231,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1191
1231
|
: `usage through ${s.usage.at ? relative(s.usage.at, now) : "now"}: today is the UTC day, 7d the last seven UTC days`;
|
|
1192
1232
|
lines.push(fitLine([span(note, s.usage?.problem ? "failure" : !s.usage?.life || s.usage.behind ? "attention" : "info")], columns));
|
|
1193
1233
|
}
|
|
1234
|
+
if (s.panel === 8 && s.rooms === null) lines.push(fitLine([span("Room status unavailable", "attention")], columns));
|
|
1194
1235
|
if (s.panel === 3 && s.backlogView) {
|
|
1195
1236
|
lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
|
|
1196
1237
|
} else if (s.panel === 3) {
|
|
@@ -1211,6 +1252,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1211
1252
|
lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
|
|
1212
1253
|
}
|
|
1213
1254
|
if (!data.length) lines.push([span(" (empty)")]);
|
|
1255
|
+
if (holders.length) lines.push(...holders);
|
|
1214
1256
|
if (s.panel === 7) for (const line of taskLines) lines.push(fitLine([span(" " + line.text, line.tone)], columns));
|
|
1215
1257
|
if (title.length) {
|
|
1216
1258
|
const room = Math.max(1, height - lines.length - 1 - tail.length);
|
package/src/cli/console.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { loadPetConfig } from "./pet-config.ts";
|
|
|
13
13
|
import { CHARACTER_IDS, petGlyphs, petSize } from "../pet/frame.ts";
|
|
14
14
|
import { activeNotice, consoleFooterRows } from "./console-state.ts";
|
|
15
15
|
import { characterAttentionLine } from "./attention.ts";
|
|
16
|
+
import { projectRoomStatus } from "../rooms/status.ts";
|
|
16
17
|
|
|
17
18
|
export interface ConsoleTerminal {
|
|
18
19
|
columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
|
|
@@ -218,7 +219,21 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
218
219
|
if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
|
|
219
220
|
if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
|
|
220
221
|
if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
|
|
221
|
-
if (status.status
|
|
222
|
+
if (status.status && status.ok !== false) {
|
|
223
|
+
const rooms = status.status.rooms;
|
|
224
|
+
state.rooms = rooms === undefined ? undefined : rooms === null ? null : Array.isArray(rooms) ? projectRoomStatus(rooms).sort((a, b) => a.name.localeCompare(b.name)) : null;
|
|
225
|
+
if (state.rooms === undefined || Array.isArray(state.rooms) && state.rooms.length === 0) {
|
|
226
|
+
if (state.panel === 8) { state.panel = 1; state.selection = 0; state.roomSelection = undefined; state.detail = undefined; state.detailOffset = 0; }
|
|
227
|
+
} else if (state.panel === 8) {
|
|
228
|
+
if (state.rooms === null) { state.roomSelection = undefined; state.selection = 0; state.detail = undefined; state.detailOffset = 0; }
|
|
229
|
+
else if (!state.roomSelection || !state.rooms.some(room => room.name === state.roomSelection)) {
|
|
230
|
+
state.roomSelection = state.rooms[0]?.name; state.selection = 0; state.detail = undefined; state.detailOffset = 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer],
|
|
235
|
+
...(typeof status.status.ownerIsolation?.[peer] === "boolean" ? { ownerIsolation: status.status.ownerIsolation[peer] } : {}),
|
|
236
|
+
...(typeof status.status.ownerIsolationNotes?.[peer] === "string" ? { ownerIsolationNote: status.status.ownerIsolationNotes[peer] } : {}) }]));
|
|
222
237
|
if (status.status && status.ok !== false) {
|
|
223
238
|
state.taskWip = status.status.taskWip ?? {};
|
|
224
239
|
state.attentionFacts = attentionFacts(status.status.attentionItems);
|
|
@@ -330,7 +345,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
330
345
|
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
331
346
|
return;
|
|
332
347
|
}
|
|
333
|
-
if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
|
|
348
|
+
if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length, state.rooms === null || Array.isArray(state.rooms) && state.rooms.length > 0).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
|
|
334
349
|
if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
|
|
335
350
|
if (action.type === "show") {
|
|
336
351
|
const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -94,7 +94,9 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
94
94
|
// A present value that is not an empty list asks, even when malformed: the hook never turns an unreadable classification into an allow.
|
|
95
95
|
const classed = res.actionClasses !== undefined && !(Array.isArray(res.actionClasses) && res.actionClasses.length === 0);
|
|
96
96
|
const classNames = Array.isArray(res.actionClasses) ? res.actionClasses.filter((key: unknown): key is string => typeof key === "string" && /^[a-z-]{1,32}$/.test(key)) : [];
|
|
97
|
-
|
|
97
|
+
// The daemon supplies this ceiling from the authenticated instance's flow roles, never a peer-id spelling.
|
|
98
|
+
const mayGrant = !Object.hasOwn(res, "mutationGrants") || res.mutationGrants === true || ["Read", "LS", "Glob", "Grep"].includes(String(input.tool_name));
|
|
99
|
+
const allow = mayGrant && !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
98
100
|
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
99
101
|
if (!allow && !classed && !text) return undefined;
|
|
100
102
|
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(classed ? { permissionDecision: "ask", permissionDecisionReason: `agent-hub always-ask${classNames.length ? `: ${classNames.join(", ")}` : ""}` } : allow ? { permissionDecision: "allow" } : {}) } });
|