@staix/agent-hub 0.13.0 → 0.15.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 +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
|
|
1
2
|
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
2
3
|
import type { SettingDef } from "../hub/settings.ts";
|
|
3
4
|
import type { Addon } from "../hub/tooling.ts";
|
|
@@ -20,7 +21,7 @@ import type { BuiltinCliEntry } from "./definitions/native-cli-entry-types.ts";
|
|
|
20
21
|
import type { NativePreviewBuilder } from "./definitions/native-cli-preview-common.ts";
|
|
21
22
|
|
|
22
23
|
export type StartForm = "tui" | "headless";
|
|
23
|
-
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
|
|
24
|
+
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print" | "claude-implementer";
|
|
24
25
|
|
|
25
26
|
/** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
|
|
26
27
|
export interface LaunchContext {
|
|
@@ -62,7 +63,8 @@ export type DaemonPorts =
|
|
|
62
63
|
| { pi: PiOptions }
|
|
63
64
|
| { local: LocalOptions }
|
|
64
65
|
| { claudePrint: ClaudePrintOptions }
|
|
65
|
-
| { reviewerPrint: ReviewerPrintOptions }
|
|
66
|
+
| { reviewerPrint: ReviewerPrintOptions }
|
|
67
|
+
| { claudeImplementer: ClaudeImplementerOptions };
|
|
66
68
|
|
|
67
69
|
export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
68
70
|
instance: string;
|
|
@@ -200,7 +202,7 @@ export interface AdapterDefinition {
|
|
|
200
202
|
ownedFlags: readonly string[];
|
|
201
203
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
202
204
|
};
|
|
203
|
-
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
205
|
+
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
204
206
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
205
207
|
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
206
208
|
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
@@ -300,6 +302,8 @@ export interface AdapterDefinition {
|
|
|
300
302
|
hooks?: Partial<NativeHooks>;
|
|
301
303
|
/** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
|
|
302
304
|
reviewerVariant?: AdapterDefinition;
|
|
305
|
+
/** #525: fixed builtin variant for declared headless implementer instances. */
|
|
306
|
+
headlessVariant?: AdapterDefinition;
|
|
303
307
|
/** Fixed host backend; runtime isolation proof is required before bus admission. */
|
|
304
308
|
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
|
|
305
309
|
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -24,7 +24,10 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION, AGY_REVIEWER_VARIANT]);
|
|
27
|
-
for (const definition of [...fixedBuiltinDefinitions])
|
|
27
|
+
for (const definition of [...fixedBuiltinDefinitions]) {
|
|
28
|
+
if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
29
|
+
if (definition.headlessVariant) fixedBuiltinDefinitions.add(definition.headlessVariant);
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
30
33
|
export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
@@ -65,6 +68,7 @@ for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
|
65
68
|
assertDefinitionCoherence(definition);
|
|
66
69
|
assertNativeCapabilityScope(definition);
|
|
67
70
|
if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
71
|
+
if (definition.headlessVariant) { assertDefinitionCoherence(definition.headlessVariant); assertNativeCapabilityScope(definition.headlessVariant); }
|
|
68
72
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
69
73
|
definitions.set(definition.kind, definition);
|
|
70
74
|
}
|
|
@@ -114,7 +118,11 @@ function resolvedDefinition(id: string, kind: string | undefined, instances: Kin
|
|
|
114
118
|
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
115
119
|
const variant = definition?.reviewerVariant;
|
|
116
120
|
const fresh = variant?.reviewer?.declaration === "automatic" || ownField(declaration, "review_mode") === "fresh";
|
|
117
|
-
|
|
121
|
+
const headless = kind !== id && ownField(declaration, "start_mode") === "headless";
|
|
122
|
+
if (variant && headless && reviewerOnly && fresh) return variant;
|
|
123
|
+
const roleValues = Array.isArray(roles) && !types.isProxy(roles) && roles.length <= 4 ? Array.from({ length: roles.length }, (_, index) => Object.getOwnPropertyDescriptor(roles, String(index))?.value) : [];
|
|
124
|
+
const writerRoles = roleValues.includes("implementer") && roleValues.every(role => role === "implementer" || role === "verifier");
|
|
125
|
+
return headless && writerRoles && definition?.headlessVariant ? definition.headlessVariant : definition;
|
|
118
126
|
}
|
|
119
127
|
export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
120
128
|
return definitionOf(id, instances)?.family;
|
|
@@ -217,6 +225,7 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
|
217
225
|
}
|
|
218
226
|
}
|
|
219
227
|
if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
|
|
228
|
+
if (definition.headlessVariant && (definition.headlessVariant.transport !== "hub-native" || !definition.headlessVariant.start.create || definition.headlessVariant.instances?.mode !== "headless")) fail("headlessVariant", "implementer variant requires its headless hub-native factory");
|
|
220
229
|
if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
|
|
221
230
|
}
|
|
222
231
|
|
|
@@ -233,7 +242,10 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
233
242
|
["routing", definition.routing !== undefined],
|
|
234
243
|
["projection", definition.projection !== undefined],
|
|
235
244
|
["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
|
|
245
|
+
["identity.currentChild", declared(definition.identity, "currentChild")],
|
|
236
246
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
247
|
+
["headlessVariant", definition.headlessVariant !== undefined],
|
|
248
|
+
["start.daemonPort claude-implementer", definition.start.daemonPort === "claude-implementer"],
|
|
237
249
|
["reviewer", definition.reviewer !== undefined],
|
|
238
250
|
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
239
251
|
["cli.entry", declared(definition.cli, "entry")],
|
|
@@ -1,11 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
4
4
|
import { HUB, frame } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
7
|
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
8
8
|
|
|
9
|
+
/** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
|
|
10
|
+
export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
|
|
11
|
+
export type ReviewerCauseClass = typeof REVIEWER_CAUSE_CLASSES[number];
|
|
12
|
+
const REVIEWER_CAUSE_HINT: Record<ReviewerCauseClass, string> = {
|
|
13
|
+
version: "the CLI version is neither verified nor trusted; see ahub agents",
|
|
14
|
+
binary: "the CLI binary or a watched config file could not be bound, or changed during the proof; check the configured binary and retry",
|
|
15
|
+
mcp_inventory: "the MCP server inventory or the hub server entry did not match the proof",
|
|
16
|
+
plugins_hooks_apps: "plugins, hooks or apps could not be proven disabled",
|
|
17
|
+
sandbox_proof: "the native sandbox proof did not pass",
|
|
18
|
+
credential_root: "a host credential root is unusable; remove or repair it",
|
|
19
|
+
timeout: "a proof command timed out",
|
|
20
|
+
unknown: "unclassified; inspect hub.log",
|
|
21
|
+
};
|
|
22
|
+
/** The one wording for the unavailable reason, the log and `ahub status`. `detail` is only ever a credential-root path class. */
|
|
23
|
+
export function reviewerUnavailableReason(cause: ReviewerCauseClass, detail?: string): string {
|
|
24
|
+
return `reviewer isolation unavailable [cause: ${cause}]: ${REVIEWER_CAUSE_HINT[cause]}${detail ? ` (${detail})` : ""}`;
|
|
25
|
+
}
|
|
26
|
+
/** A prepare refusal that carries its class to the host; the message stays the backend's own text. */
|
|
27
|
+
export class ReviewerUnavailableError extends Error {
|
|
28
|
+
constructor(message: string, readonly causeClass: ReviewerCauseClass, readonly detail?: string) { super(message); }
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
10
32
|
export interface ReviewerIsolationReceipt {
|
|
11
33
|
backend: string;
|
|
@@ -51,7 +73,7 @@ export interface ReviewerBackend {
|
|
|
51
73
|
id: string;
|
|
52
74
|
taskMcp?: true;
|
|
53
75
|
nativePolicy?: true;
|
|
54
|
-
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
76
|
+
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }>;
|
|
55
77
|
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
56
78
|
failure?(event: unknown): "quota" | undefined;
|
|
57
79
|
}
|
|
@@ -60,6 +82,9 @@ export interface ReviewerFailure {
|
|
|
60
82
|
head?: string;
|
|
61
83
|
flowRevision?: number;
|
|
62
84
|
cause: "start_failed" | "quota" | "no_verdict";
|
|
85
|
+
/** Only for `start_failed`: the leg that refused (#722). */
|
|
86
|
+
causeClass?: ReviewerCauseClass;
|
|
87
|
+
causeDetail?: string;
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
|
|
@@ -82,9 +107,6 @@ export interface ReviewerPrintOptions {
|
|
|
82
107
|
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
83
108
|
}
|
|
84
109
|
|
|
85
|
-
// ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
|
|
86
|
-
const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
87
|
-
class GroupCleanupError extends Error {}
|
|
88
110
|
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
89
111
|
interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
|
|
90
112
|
const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
@@ -93,8 +115,8 @@ const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
|
93
115
|
const revision = Number(env.refs?.reviewRevision);
|
|
94
116
|
return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
|
|
95
117
|
};
|
|
96
|
-
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
|
|
97
|
-
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
|
|
118
|
+
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"], why?: { causeClass: ReviewerCauseClass; causeDetail?: string }): ReviewerFailure[] =>
|
|
119
|
+
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause, ...(why ? { causeClass: why.causeClass, ...(why.causeDetail ? { causeDetail: why.causeDetail } : {}) } : {}) }] : []);
|
|
98
120
|
const reviewTask = (env: Envelope): number | undefined => {
|
|
99
121
|
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
100
122
|
const id = Number(env.refs?.task);
|
|
@@ -104,8 +126,7 @@ const reviewTask = (env: Envelope): number | undefined => {
|
|
|
104
126
|
export class ReviewerPrintPeer extends BasePeer {
|
|
105
127
|
readonly hubNative = true;
|
|
106
128
|
private generation = 0;
|
|
107
|
-
private child:
|
|
108
|
-
private childStop: ((child: ChildProcess) => Promise<void>) | undefined;
|
|
129
|
+
private child: OwnedPrintChild | undefined;
|
|
109
130
|
private childCleanup: (() => Promise<void>) | undefined;
|
|
110
131
|
private childCurrent: (() => boolean) | undefined;
|
|
111
132
|
private reviewMcpClose: (() => void) | undefined;
|
|
@@ -217,7 +238,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
217
238
|
try { result = await this.review(env, task, generation); }
|
|
218
239
|
catch (error) {
|
|
219
240
|
if (generation !== this.generation) return;
|
|
220
|
-
if (error instanceof
|
|
241
|
+
if (error instanceof PrintChildCleanupError) {
|
|
221
242
|
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
222
243
|
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
223
244
|
this.settle("needs_review", `review process group still running: ${error.message}`);
|
|
@@ -225,7 +246,9 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
225
246
|
}
|
|
226
247
|
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
227
248
|
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
|
|
228
|
-
|
|
249
|
+
const why = error instanceof ReviewerUnavailableError ? { causeClass: error.causeClass, causeDetail: error.detail } : { causeClass: "unknown" as const };
|
|
250
|
+
this.opts.log?.(`[${this.id}] review of task #${task} could not start [cause: ${why.causeClass}]`);
|
|
251
|
+
reroute.push(...remainingFailures(batch, index, "start_failed", why));
|
|
229
252
|
batch.next = envs.length;
|
|
230
253
|
break;
|
|
231
254
|
}
|
|
@@ -273,14 +296,14 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
273
296
|
mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
|
|
274
297
|
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
|
|
275
298
|
...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
|
|
276
|
-
if ("unavailable" in launch) throw new
|
|
299
|
+
if ("unavailable" in launch) throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
|
|
277
300
|
const proof = launch.isolation;
|
|
278
301
|
const policy = this.opts.backend.nativePolicy
|
|
279
302
|
? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
|
|
280
303
|
: proof?.strictMcp === true && proof.ephemeral === true;
|
|
281
304
|
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
282
305
|
await launch.cleanup?.();
|
|
283
|
-
throw new
|
|
306
|
+
throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
|
|
284
307
|
}
|
|
285
308
|
return launch;
|
|
286
309
|
})();
|
|
@@ -302,60 +325,41 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
302
325
|
let mcpClosed=false;
|
|
303
326
|
const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
|
|
304
327
|
this.reviewMcpClose=closeMcp;
|
|
305
|
-
let ownedChild:
|
|
328
|
+
let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
|
|
306
329
|
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
307
330
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
308
331
|
try {
|
|
309
332
|
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
310
|
-
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new
|
|
333
|
+
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
|
|
311
334
|
this.receipt = launch.isolation;
|
|
312
|
-
const [cmd, ...prefix] = launch.command;
|
|
313
335
|
let quota = false;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
ownedChild = child;
|
|
320
|
-
this.childStop = launch!.stop;
|
|
321
|
-
this.childCleanup = release;
|
|
322
|
-
this.childCurrent = launch!.watchCurrent ? launch!.current : undefined;
|
|
323
|
-
child.once("error", error => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } reject(error); });
|
|
324
|
-
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
325
|
-
child.once("spawn", () => {
|
|
326
|
-
trackGroup(child);
|
|
327
|
-
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
336
|
+
const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
|
|
337
|
+
cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
|
|
338
|
+
current: () => generation === this.generation, stop: launch.stop,
|
|
339
|
+
onError: () => { if (this.child === child) this.child = undefined; },
|
|
340
|
+
onSpawn: () => {
|
|
328
341
|
this.runningBinding={task,binding};
|
|
329
342
|
this.reviewing = task; this.reviewSeq++;
|
|
330
343
|
if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const event: unknown = JSON.parse(line);
|
|
344
|
-
const usage = this.opts.backend.usage?.(event);
|
|
345
|
-
if (usage) this.opts.onUsage?.(usage, task);
|
|
346
|
-
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
347
|
-
} catch { /* not a stream-json line */ }
|
|
348
|
-
}
|
|
349
|
-
if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
350
|
-
this.touch();
|
|
351
|
-
});
|
|
352
|
-
child.once("exit", code => {
|
|
353
|
-
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; } // the verdict authority ends with the leader
|
|
354
|
-
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
355
|
-
(launch!.stop ? launch!.stop(child) : stopOwnedProcess(child, { group: true })).then(() => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } resolve({ code, quota }); },
|
|
356
|
-
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
357
|
-
});
|
|
344
|
+
},
|
|
345
|
+
onFrame: event => {
|
|
346
|
+
const usage = this.opts.backend.usage?.(event);
|
|
347
|
+
if (usage) this.opts.onUsage?.(usage, task);
|
|
348
|
+
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
349
|
+
},
|
|
350
|
+
onActivity: () => this.touch(),
|
|
351
|
+
onLimit: () => this.abortReview("review output limit exceeded"),
|
|
352
|
+
onLeaderExit: () => {
|
|
353
|
+
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; }
|
|
354
|
+
},
|
|
358
355
|
});
|
|
356
|
+
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
357
|
+
this.child = ownedChild = child;
|
|
358
|
+
this.childCleanup = release;
|
|
359
|
+
this.childCurrent = launch.watchCurrent ? launch.current : undefined;
|
|
360
|
+
const code = await child.done;
|
|
361
|
+
if (this.child === child) this.child = undefined;
|
|
362
|
+
return { code, quota };
|
|
359
363
|
} finally {
|
|
360
364
|
closeMcp();
|
|
361
365
|
if (policyTimer) clearInterval(policyTimer);
|
|
@@ -376,8 +380,8 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
376
380
|
this.runningBinding=undefined;
|
|
377
381
|
const child = this.child;
|
|
378
382
|
if (!child) return;
|
|
379
|
-
await
|
|
380
|
-
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.
|
|
383
|
+
await child.stop();
|
|
384
|
+
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
381
385
|
}
|
|
382
386
|
}
|
|
383
387
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #689: the console's structural config form (Settings panel, key `c`). A pure reducer: it collects answers, builds the one request the
|
|
3
|
+
* hub validates (`config_edit_preview`, then `config_edit_apply`) and never judges a value itself. The loaders' reasons come back from
|
|
4
|
+
* the hub and are shown as they are. A tier 3 key is confirmed by typing its name, never by `y`.
|
|
5
|
+
*/
|
|
6
|
+
export interface ConfigEditBody { file: "config.local.json" | "sdlc.toml"; revision: string; set: { path: string[]; value: unknown }[] }
|
|
7
|
+
export type ConfigFormKind = "instance" | "sdlc" | "other";
|
|
8
|
+
export interface ConfigForm {
|
|
9
|
+
kind: ConfigFormKind; token: number;
|
|
10
|
+
/** The instance ids the hub reports: an id in it changes leaf by leaf, a new id is added as one object. */
|
|
11
|
+
known?: readonly string[];
|
|
12
|
+
/** True until the person has chosen what to edit (1, 2 or 3). */
|
|
13
|
+
choosing?: boolean;
|
|
14
|
+
phase: "fields" | "preview" | "reason" | "confirm" | "typed";
|
|
15
|
+
step: number; answers: string[]; text: string;
|
|
16
|
+
edit?: ConfigEditBody; lines?: string[]; reason?: string;
|
|
17
|
+
/** The key a tier 3 edit must be confirmed with, from the hub's preview. */
|
|
18
|
+
typed?: string; warning?: string;
|
|
19
|
+
}
|
|
20
|
+
export type ConfigFormEffect =
|
|
21
|
+
| { type: "config_preview"; token: number; edit: ConfigEditBody }
|
|
22
|
+
| { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string };
|
|
23
|
+
export interface ConfigFormStep { form?: ConfigForm; effect?: ConfigFormEffect; note?: string }
|
|
24
|
+
|
|
25
|
+
const FIELDS: Record<ConfigFormKind, readonly { name: string; ask: string }[]> = {
|
|
26
|
+
instance: [
|
|
27
|
+
{ name: "id", ask: "instance id, <kind>-<name> (e.g. codex-review)" }, { name: "kind", ask: "kind (claude, codex, kimi, jcode, ...; empty for an instance that exists already)" },
|
|
28
|
+
{ name: "roles", ask: "roles, comma list (planner, implementer, verifier, reviewer; empty leaves it)" },
|
|
29
|
+
{ name: "start_mode", ask: "start_mode (headless; empty leaves it, clear removes it)" }, { name: "review_mode", ask: "review_mode (fresh; empty leaves it unset)" },
|
|
30
|
+
{ name: "worktree", ask: "worktree path (empty leaves it unset)" }, { name: "permission_mode", ask: "permission_mode (ask, ask-when-needed, never-ask; empty leaves it unset)" },
|
|
31
|
+
{ name: "readOnly", ask: "readOnly (true or false; empty leaves it unset)" },
|
|
32
|
+
],
|
|
33
|
+
sdlc: [{ name: "mode", ask: "sdlc mode (off, advise, enforce; empty leaves it)" }, { name: "required_checks", ask: "required checks, comma list (empty leaves them)" }],
|
|
34
|
+
other: [{ name: "path", ask: "key path, dot separated (e.g. review.pool)" }, { name: "json", ask: "JSON value (empty removes the key)" }],
|
|
35
|
+
};
|
|
36
|
+
const a1x = (text: string | undefined): text is string => !!text;
|
|
37
|
+
const list = (text: string): string[] => text.split(",").map(item => item.trim()).filter(Boolean);
|
|
38
|
+
|
|
39
|
+
/** `1` an instance, `2` the lifecycle, `3` another structural key; anything else opens nothing. */
|
|
40
|
+
export function openConfigForm(choice: string, token: number, known: readonly string[] = []): ConfigForm | undefined {
|
|
41
|
+
const kind = ({ "1": "instance", "2": "sdlc", "3": "other" } as const)[choice as "1"];
|
|
42
|
+
return kind ? { kind, token, phase: "fields", step: 0, answers: [], text: "", ...(known.length ? { known } : {}) } : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function configFormPrompt(form: ConfigForm): string {
|
|
46
|
+
if (form.choosing) return "edit: 1 instance 2 sdlc lifecycle 3 another key Esc cancel";
|
|
47
|
+
if (form.phase === "fields") return `${FIELDS[form.kind][form.step]!.ask}: ${form.text}`;
|
|
48
|
+
if (form.phase === "preview") return "checking the change with the hub's loaders; Esc cancels";
|
|
49
|
+
if (form.phase === "reason") return `reason: ${form.text}`;
|
|
50
|
+
if (form.phase === "typed") return `${form.warning ?? "this runs code with the daemon's rights"}; type ${form.typed} to confirm: ${form.text}`;
|
|
51
|
+
return `write ${form.edit?.file} (${form.reason})? y/N`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The request the answers describe, or the first reason they cannot. */
|
|
55
|
+
function build(form: ConfigForm, revisions: Record<string, string>): { edit: ConfigEditBody } | { note: string } {
|
|
56
|
+
const [a0 = "", a1 = ""] = form.answers;
|
|
57
|
+
if (form.kind === "instance") {
|
|
58
|
+
const names = ["kind", "roles", "start_mode", "review_mode", "worktree", "permission_mode", "readOnly"] as const;
|
|
59
|
+
const typed = names.flatMap((name, index) => a1x(form.answers[index + 1]) ? [[name, form.answers[index + 1]!] as const] : []);
|
|
60
|
+
const ro = typed.find(([name]) => name === "readOnly");
|
|
61
|
+
if (ro && !["true", "false", "clear"].includes(ro[1])) return { note: "readOnly is true or false (or clear)" };
|
|
62
|
+
const convert = (name: string, text: string): unknown => name === "roles" ? list(text) : name === "readOnly" ? text === "true" : text;
|
|
63
|
+
const file = revisions["config.local.json"] ?? "absent";
|
|
64
|
+
if (form.known?.includes(a0)) {
|
|
65
|
+
// An existing instance changes leaf by leaf: a blank field keeps its value, and a tier 3 field it holds is never part of the request.
|
|
66
|
+
if (!typed.length) return { note: "type a field to change, or clear to remove one" };
|
|
67
|
+
return { edit: { file: "config.local.json", revision: file, set: typed.map(([name, text]) => ({ path: ["instances", a0, name], value: text === "clear" ? null : convert(name, text) })) } };
|
|
68
|
+
}
|
|
69
|
+
if (!a1) return { note: "the kind is required for a new instance" };
|
|
70
|
+
const value: Record<string, unknown> = {};
|
|
71
|
+
for (const [name, text] of typed) if (text !== "clear") value[name] = convert(name, text);
|
|
72
|
+
return { edit: { file: "config.local.json", revision: file, set: [{ path: ["instances", a0], value }] } };
|
|
73
|
+
}
|
|
74
|
+
if (form.kind === "sdlc") {
|
|
75
|
+
const set: ConfigEditBody["set"] = [];
|
|
76
|
+
if (a0) set.push({ path: ["mode"], value: a0 });
|
|
77
|
+
if (a1) set.push({ path: ["required_checks"], value: list(a1) });
|
|
78
|
+
if (!set.length) return { note: "give a mode or the required checks" };
|
|
79
|
+
return { edit: { file: "sdlc.toml", revision: revisions["sdlc.toml"] ?? "absent", set } };
|
|
80
|
+
}
|
|
81
|
+
let value: unknown = null;
|
|
82
|
+
if (a1.trim()) { try { value = JSON.parse(a1); } catch { return { note: "the value is not valid JSON (strings need their quotes)" }; } }
|
|
83
|
+
return { edit: { file: "config.local.json", revision: revisions["config.local.json"] ?? "absent", set: [{ path: a0.split("."), value }] } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One key. A chunk longer than one key is pasted text: it is text in a text phase and never an answer to y/N. */
|
|
87
|
+
export function reduceConfigForm(form: ConfigForm, key: string, revisions: Record<string, string>): ConfigFormStep {
|
|
88
|
+
if (key === "\x1b") return { form: undefined };
|
|
89
|
+
if (form.choosing) {
|
|
90
|
+
const chosen = key.length === 1 ? openConfigForm(key, form.token, form.known) : undefined;
|
|
91
|
+
return { form: chosen ?? form };
|
|
92
|
+
}
|
|
93
|
+
if (form.phase === "preview") return { form };
|
|
94
|
+
if (form.phase === "confirm") {
|
|
95
|
+
if (key === "n") return { form: undefined };
|
|
96
|
+
if (key !== "y") return { form };
|
|
97
|
+
return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason! } };
|
|
98
|
+
}
|
|
99
|
+
const text = (current: ConfigForm): ConfigFormStep => {
|
|
100
|
+
if (key === "\x7f" || key === "\b") return { form: { ...current, text: Array.from(current.text).slice(0, -1).join("") } };
|
|
101
|
+
if (key === "\x15") return { form: { ...current, text: "" } };
|
|
102
|
+
if (key.startsWith("\x1b") || key === "\r" || key === "\n") return { form: current };
|
|
103
|
+
return { form: { ...current, text: (current.text + key.replace(/[\x00-\x1f\x7f]/g, "")).slice(0, 4000) } };
|
|
104
|
+
};
|
|
105
|
+
if (key !== "\r" && key !== "\n") return text(form);
|
|
106
|
+
const answer = form.text.trim();
|
|
107
|
+
if (form.phase === "reason") {
|
|
108
|
+
if (!answer || Buffer.byteLength(answer) > 400) return { form, note: "give a reason from 1 to 400 UTF-8 bytes" };
|
|
109
|
+
return { form: { ...form, reason: answer, text: "", phase: form.typed ? "typed" : "confirm" } };
|
|
110
|
+
}
|
|
111
|
+
if (form.phase === "typed") {
|
|
112
|
+
if (answer !== form.typed) return { form, note: `type exactly ${form.typed}` };
|
|
113
|
+
return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason!, confirm: form.typed } };
|
|
114
|
+
}
|
|
115
|
+
// phase "fields"
|
|
116
|
+
if (form.kind === "instance" && form.step === 0 && !answer) return { form, note: "an instance id is required" };
|
|
117
|
+
if (form.kind === "other" && form.step === 0 && (!answer || answer.startsWith(".") || answer.endsWith(".") || answer.includes(".."))) return { form, note: "give a key path such as review.pool" };
|
|
118
|
+
const answers = [...form.answers, answer];
|
|
119
|
+
if (form.step + 1 < FIELDS[form.kind].length) return { form: { ...form, answers, step: form.step + 1, text: "" } };
|
|
120
|
+
const built = build({ ...form, answers }, revisions);
|
|
121
|
+
if ("note" in built) return { form: { ...form, answers: answers.slice(0, -1), text: answer }, note: built.note };
|
|
122
|
+
return { form: { ...form, answers, text: "", phase: "preview", edit: built.edit }, effect: { type: "config_preview", token: form.token, edit: built.edit } };
|
|
123
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { sanitize } from "../hub/envelope.ts";
|
|
|
9
9
|
import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
10
10
|
import { symbolFields, overlapFields } from "./console-symbols.ts";
|
|
11
11
|
import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
|
|
12
|
+
import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
|
|
12
13
|
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
13
14
|
|
|
14
15
|
export interface Approval {
|
|
@@ -24,6 +25,8 @@ export interface SettingsEdit {
|
|
|
24
25
|
text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
|
|
25
26
|
}
|
|
26
27
|
export interface ConsoleState {
|
|
28
|
+
/** #689: the structural config form (Settings panel, key c) and the file revisions the hub last reported. */
|
|
29
|
+
configForm?: ConfigForm; configRevisions?: Record<string, string>; configInstances?: string[];
|
|
27
30
|
settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
|
|
28
31
|
settingsEdit?: SettingsEdit; settingsSequence?: number;
|
|
29
32
|
pendingRestart?: { key: string; reason: string }[];
|
|
@@ -65,6 +68,7 @@ export interface ConsoleState {
|
|
|
65
68
|
export type Detail = string | Record<string, unknown>;
|
|
66
69
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
67
70
|
{ type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
|
|
71
|
+
{ type: "config_preview"; token: number; edit: ConfigEditBody } | { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string } |
|
|
68
72
|
{ type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
|
|
69
73
|
{ type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
70
74
|
export function initialConsoleState(panels = false): ConsoleState {
|
|
@@ -490,6 +494,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
490
494
|
const done = () => ({ state: bound(s), effects });
|
|
491
495
|
const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
|
|
492
496
|
if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
|
|
497
|
+
if (s.configForm) {
|
|
498
|
+
const step = reduceConfigForm(s.configForm, key, s.configRevisions ?? {});
|
|
499
|
+
s.configForm = step.form;
|
|
500
|
+
if (step.note) notify(s, step.note, now);
|
|
501
|
+
if (step.effect) effects.push(step.effect);
|
|
502
|
+
return done();
|
|
503
|
+
}
|
|
493
504
|
if (s.settingsEdit) {
|
|
494
505
|
const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
|
|
495
506
|
if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
|
|
@@ -684,6 +695,14 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
684
695
|
s.settingsEdit = { token, panel: 1, key: row.key, undo: false, phase: "value", text: String(wip.wipLimit) };
|
|
685
696
|
return done();
|
|
686
697
|
}
|
|
698
|
+
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "c") {
|
|
699
|
+
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
700
|
+
s.configForm = { kind: "other", token, phase: "fields", step: 0, answers: [], text: "", choosing: true, ...(s.configInstances?.length ? { known: s.configInstances } : {}) };
|
|
701
|
+
return done();
|
|
702
|
+
}
|
|
703
|
+
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "R" && s.pendingRestart?.length) {
|
|
704
|
+
s.input = "restart"; s.editing = true; return done();
|
|
705
|
+
}
|
|
687
706
|
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
|
|
688
707
|
const row = selectedPanelRow(s);
|
|
689
708
|
if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
|
|
@@ -962,8 +981,9 @@ function taskHints(row: any): string[] {
|
|
|
962
981
|
const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
|
|
963
982
|
function panelContext(s: ConsoleState, row: any): string[] {
|
|
964
983
|
if (s.panel === 3) return [...(row ? taskHints(row) : []), ...(row && s.features?.includes("task_cancel") ? ["c cancel"] : []), s.backlogView ? "b open" : "b backlog", "n add"];
|
|
984
|
+
// The structural config form and the restart prefill need no selected row (#689).
|
|
985
|
+
if (s.panel === 6) return [...(row ? ["e edit"] : []), "c config", ...(s.settingsUndo ? ["u undo"] : []), ...(s.pendingRestart?.length ? ["R restart"] : [])];
|
|
965
986
|
if (!row) return [];
|
|
966
|
-
if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
|
|
967
987
|
if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
|
|
968
988
|
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
|
|
969
989
|
return [];
|
|
@@ -1044,6 +1064,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1044
1064
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
1045
1065
|
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
1046
1066
|
if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
|
|
1067
|
+
if (s.configForm) prompt = configFormPrompt(s.configForm);
|
|
1047
1068
|
if (s.settingsEdit) {
|
|
1048
1069
|
const edit = s.settingsEdit;
|
|
1049
1070
|
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`;
|
|
@@ -1094,6 +1115,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1094
1115
|
const height = bodyHeight - noticeLines.length;
|
|
1095
1116
|
const lines: Span[][] = [];
|
|
1096
1117
|
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
1118
|
+
else if (s.configForm && !s.configForm.choosing && (s.configForm.lines?.length || s.configForm.phase !== "fields")) {
|
|
1119
|
+
// #689: what the hub's loaders said about the request, as the person will confirm it: the diff, then the decision.
|
|
1120
|
+
const form = s.configForm;
|
|
1121
|
+
lines.push(...fieldLines([["file", form.edit?.file ?? "-"], ["diff", (form.lines ?? ["checking with the hub's loaders..."]).join("\n")], ...(form.warning ? [["warning", form.warning] as [string, unknown]] : []),
|
|
1122
|
+
["decision", form.typed ? `Esc cancels; type ${form.typed} to confirm` : "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
|
|
1123
|
+
}
|
|
1097
1124
|
else if (s.settingsEdit) {
|
|
1098
1125
|
const edit = s.settingsEdit;
|
|
1099
1126
|
const row = s.settingsRows?.find(row => row.key === edit.key);
|
package/src/cli/console.ts
CHANGED
|
@@ -102,7 +102,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
102
102
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
103
103
|
let priorStreamFooterStart: number | undefined;
|
|
104
104
|
let lastExpandedNotice = "";
|
|
105
|
-
const notice = (text: string) => notify(state, text, Date.now());
|
|
105
|
+
const notice = (text: string, tone: Tone = "failure") => notify(state, text, Date.now(), tone);
|
|
106
106
|
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
107
107
|
const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
|
|
108
108
|
// Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
|
|
@@ -243,6 +243,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
243
243
|
if (settings.ok && Array.isArray(settings.rows)) {
|
|
244
244
|
state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
|
|
245
245
|
state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
|
|
246
|
+
const revisions = (settings.configEdit as { revisions?: unknown } | undefined)?.revisions;
|
|
247
|
+
const known = (settings.configEdit as { known?: unknown } | undefined)?.known;
|
|
248
|
+
state.configInstances = Array.isArray(known) ? known.map(String).filter(Boolean) : [];
|
|
249
|
+
if (revisions && typeof revisions === "object") state.configRevisions = Object.fromEntries(Object.entries(revisions).filter(([, value]) => typeof value === "string")) as Record<string, string>;
|
|
246
250
|
state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
|
|
247
251
|
state.retainedConfig = Array.isArray(settings.retainedConfig) ? settings.retainedConfig : [];
|
|
248
252
|
} else state.settingsProblem = String(settings.error ?? "settings are unavailable");
|
|
@@ -282,6 +286,26 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
282
286
|
} else state.settingsEdit = { ...edit, ...(action.undo ? { key: response.key, undoDigest: response.undoDigest } : {}), phase: "reason", text: "", lines: [...(Array.isArray(response.lines) ? response.lines.map(String) : []), ...(Array.isArray(response.tasks) ? response.tasks.flatMap((task: any) => [String(task.before?.join("\n") ?? ""), String(task.after?.join("\n") ?? "")]) : []), ...(response.hidden ? [`${response.hidden} more open tasks are not shown`] : [])], ...(typeof response.confirm === "string" ? { confirm: response.confirm } : {}) };
|
|
283
287
|
draw(); return;
|
|
284
288
|
}
|
|
289
|
+
if (action.type === "config_preview") {
|
|
290
|
+
const response = await client.request({ t: "config_edit_preview", edit: action.edit }, 5000);
|
|
291
|
+
const form = state.configForm;
|
|
292
|
+
// A reply for a form that was cancelled or replaced is dropped: it never opens a confirmation of its own.
|
|
293
|
+
if (!active || state.mode !== "panels" || !form || form.token !== action.token) return; // every form has its own token and one outstanding preview
|
|
294
|
+
if (response.ok === false) { state.configForm = undefined; notice(String(response.error ?? "config preview refused")); }
|
|
295
|
+
else state.configForm = { ...form, phase: "reason", text: "", lines: (Array.isArray(response.lines) ? response.lines : []).map(String).slice(0, 40),
|
|
296
|
+
...(typeof response.confirm === "string" ? { typed: response.confirm, warning: typeof response.warning === "string" ? response.warning : undefined } : {}) };
|
|
297
|
+
draw(); return;
|
|
298
|
+
}
|
|
299
|
+
if (action.type === "config_write") {
|
|
300
|
+
const result = await client.request({ t: "config_edit_apply", edit: action.edit, reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
|
|
301
|
+
if (!active) return;
|
|
302
|
+
if (result.ok === false) notice(String(result.error ?? "config write refused"));
|
|
303
|
+
else {
|
|
304
|
+
const text = [String(result.text ?? "config saved"), ...(Array.isArray(result.restartPending) && result.restartPending.length ? [`restart pending: ${result.restartPending.join(", ")}`] : [])].join("; ");
|
|
305
|
+
stream({ text, kind: "settings" }); notice(text, "info");
|
|
306
|
+
}
|
|
307
|
+
await refresh(); draw(); return;
|
|
308
|
+
}
|
|
285
309
|
if (action.type === "settings_write") {
|
|
286
310
|
const result = await client.request({ t: action.undo ? "settings_undo" : "settings_set", key: action.key, ...(!action.undo ? { value: action.value } : { undoDigest: action.undoDigest }), reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
|
|
287
311
|
if (!active) return;
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -65,7 +65,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
65
65
|
const port = definitionOf(peer)?.hooks?.factsHook;
|
|
66
66
|
const payload: unknown = JSON.parse(stdin);
|
|
67
67
|
if (!port?.decode(nativeEvent, payload)) return undefined;
|
|
68
|
-
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
68
|
+
const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
|
|
69
69
|
try {
|
|
70
70
|
const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
|
|
71
71
|
const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
|
|
@@ -74,7 +74,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
74
74
|
}
|
|
75
75
|
const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
|
|
76
76
|
const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
77
|
-
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
77
|
+
const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
|
|
78
78
|
try {
|
|
79
79
|
const res = await hub.request({
|
|
80
80
|
t: "facts",
|
|
@@ -90,10 +90,14 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
90
90
|
if (phase !== "pre" || !res?.ok) return undefined;
|
|
91
91
|
const permission = isPermissionMode(res.permission) ? res.permission : "ask";
|
|
92
92
|
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot, res.protectInstructionFiles === true);
|
|
93
|
-
|
|
93
|
+
// Always-ask (#636): the hub classed this call (publish, external-write, destroy, install). `ask` wins over every allow, never-ask included.
|
|
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
|
+
const classed = res.actionClasses !== undefined && !(Array.isArray(res.actionClasses) && res.actionClasses.length === 0);
|
|
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
|
+
const allow = !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
94
98
|
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
95
|
-
if (!allow && !text) return undefined;
|
|
96
|
-
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(allow ? { permissionDecision: "allow" } : {}) } });
|
|
99
|
+
if (!allow && !classed && !text) return undefined;
|
|
100
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(classed ? { permissionDecision: "ask", permissionDecisionReason: `agent-hub always-ask${classNames.length ? `: ${classNames.join(", ")}` : ""}` } : allow ? { permissionDecision: "allow" } : {}) } });
|
|
97
101
|
} finally {
|
|
98
102
|
hub.close();
|
|
99
103
|
}
|