@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
package/src/cli/help.ts
CHANGED
|
@@ -35,6 +35,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
35
35
|
["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
|
|
36
36
|
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
|
|
37
37
|
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
38
|
+
["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
|
|
38
39
|
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
39
40
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
40
41
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
@@ -72,6 +73,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
72
73
|
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
|
|
73
74
|
["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
74
75
|
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
76
|
+
["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
|
|
75
77
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
76
78
|
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
77
79
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
@@ -80,9 +82,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
80
82
|
]],
|
|
81
83
|
["Tasks and review", [
|
|
82
84
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
83
|
-
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
|
|
85
|
+
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
|
|
84
86
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
85
|
-
["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
|
|
87
|
+
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits; list is a table by rank (--json: the raw listing)"],
|
|
86
88
|
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
|
87
89
|
["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
|
|
88
90
|
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
@@ -95,7 +97,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
95
97
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
96
98
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task; the person can do the same in ahub console: Tasks, select the task, r, then a (approve) or c (request changes, with a note)"],
|
|
97
99
|
["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
|
|
98
|
-
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
100
|
+
["ahub route explain <id>", "why a task went where it went, its reserved owner and the planner walk included"],
|
|
99
101
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
100
102
|
["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
|
|
101
103
|
["ahub remember <text...>", "save a note to the memory all agents share"],
|
|
@@ -142,7 +144,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
142
144
|
]],
|
|
143
145
|
["Dashboard", [
|
|
144
146
|
["ahub ui [--no-open]", "open the local dashboard with configured attention character (or print its one-time link)"],
|
|
145
|
-
["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and
|
|
147
|
+
["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing, start settings and structural config"],
|
|
146
148
|
["ahub ui --all [--no-open]", "open the unified project dashboard"],
|
|
147
149
|
["ahub ui --all --stop", "stop only the dashboard manager"],
|
|
148
150
|
["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
|
package/src/cli/launch.ts
CHANGED
|
@@ -30,10 +30,14 @@ export function instanceLaunchArguments(command: string, raw: readonly string[],
|
|
|
30
30
|
const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
|
|
31
31
|
if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
|
|
32
32
|
// #223: a headless reviewer is started by the hub, never from this terminal.
|
|
33
|
-
|
|
33
|
+
const runtime = definitionOf(id, instances), base = definitionOf(command);
|
|
34
|
+
hubStarted = runtime === base?.reviewerVariant || runtime === base?.headlessVariant;
|
|
34
35
|
instance = id;
|
|
35
36
|
}
|
|
36
|
-
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run"))
|
|
37
|
+
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) {
|
|
38
|
+
const writer = definitionOf(instance!, instances) === definitionOf(command)?.headlessVariant;
|
|
39
|
+
throw new Error(`${instance} is a headless ${writer ? "implementer the hub runs per task" : "reviewer the hub runs per review"}: ahub ${command} --as ${instance} takes no launch arguments`);
|
|
40
|
+
}
|
|
37
41
|
return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -108,6 +112,8 @@ export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
|
108
112
|
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
109
113
|
/** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
|
|
110
114
|
peer?: string;
|
|
115
|
+
/** Validated terminal instance root; absent for legacy/canonical launches. */
|
|
116
|
+
executionCwd?: string;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
@@ -172,10 +178,10 @@ export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord)
|
|
|
172
178
|
|
|
173
179
|
function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
|
|
174
180
|
return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
|
|
175
|
-
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
|
|
181
|
+
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature && left.executionCwd === right.executionCwd;
|
|
176
182
|
}
|
|
177
183
|
function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
|
|
178
|
-
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
|
|
184
|
+
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature, ...(launch.executionCwd ? [launch.executionCwd] : [])])).digest("hex");
|
|
179
185
|
return join(stateDir, `claude-native-${hash}.json`);
|
|
180
186
|
}
|
|
181
187
|
function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {
|
package/src/cli/main.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { matchesPolicy } from "../hub/path-policy.ts";
|
|
|
18
18
|
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
19
19
|
import { TextProjections } from "../hub/text-projection.ts";
|
|
20
20
|
import { terminalText } from "./console-state.ts";
|
|
21
|
+
import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
|
|
21
22
|
import { petView, readPet, renderPet } from "./pet.ts";
|
|
22
23
|
import { startPetWindow, closePetWindow } from "../pet/window.ts";
|
|
23
24
|
import { loadPetConfig, savePetPalette } from "./pet-config.ts";
|
|
@@ -36,7 +37,7 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
36
37
|
import { randomUUID } from "node:crypto";
|
|
37
38
|
import { homedir } from "node:os";
|
|
38
39
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
39
|
-
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
40
|
+
import { retryLaunchConnect, ControlClient, readControl } from "../hub/control-client.ts";
|
|
40
41
|
import { loadConfig } from "../hub/daemon.ts";
|
|
41
42
|
import { factsHook } from "./facts-hook.ts";
|
|
42
43
|
import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
|
|
@@ -89,7 +90,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
|
|
|
89
90
|
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
90
91
|
import { runConsole } from "./console.ts";
|
|
91
92
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
92
|
-
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
|
|
93
|
+
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
|
|
93
94
|
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
94
95
|
import { renderTailEvent } from "./tail-render.ts";
|
|
95
96
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -274,13 +275,15 @@ function printOutput(lines: Span[][], color: boolean): void {
|
|
|
274
275
|
}
|
|
275
276
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
276
277
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
277
|
-
const connect = async () => {
|
|
278
|
+
const connect = async (timeoutMs = 3000) => {
|
|
278
279
|
assertDelegatedAuditConsumer();
|
|
279
280
|
if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
|
|
280
281
|
return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
|
|
281
|
-
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
|
|
282
|
+
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) }, timeoutMs);
|
|
282
283
|
};
|
|
283
284
|
|
|
285
|
+
const connectLaunch = () => retryLaunchConnect(connect, () => console.error("hub busy, retrying"));
|
|
286
|
+
|
|
284
287
|
function registeredProject(): Project {
|
|
285
288
|
const registry = new Registry();
|
|
286
289
|
try { return registry.register(cwd, stateDir); }
|
|
@@ -943,7 +946,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
943
946
|
// Preserve the original guard before native effects; previews remain read-only and precede this guard.
|
|
944
947
|
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
945
948
|
const nativeConnect = async () => {
|
|
946
|
-
const hub = await
|
|
949
|
+
const hub = await connectLaunch();
|
|
947
950
|
try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
|
|
948
951
|
catch (error) { hub.close(); throw error; }
|
|
949
952
|
};
|
|
@@ -955,7 +958,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
955
958
|
projectConfig, connect: nativeConnect, fail,
|
|
956
959
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
957
960
|
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
958
|
-
helpers: {
|
|
961
|
+
helpers: { resolveExecutionCwd: peer => {
|
|
962
|
+
const current = readInstanceRoleConfig(cwd);
|
|
963
|
+
return resolveInstanceExecutionCwd(cwd, peer, current.instances, effectiveInstanceRoles(peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
964
|
+
}, launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
|
|
959
965
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
960
966
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
961
967
|
buildKimiTuiLaunch, startModeOf },
|
|
@@ -1141,7 +1147,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1141
1147
|
|
|
1142
1148
|
backlog: async () => {
|
|
1143
1149
|
const [sub, id, revision, value, ...reason] = args;
|
|
1144
|
-
if (sub === "list" &&
|
|
1150
|
+
if (sub === "list" && outputArguments().length === 1) {
|
|
1151
|
+
// #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
|
|
1152
|
+
const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
|
|
1153
|
+
if (options.json) return console.log(raw);
|
|
1154
|
+
let cards: unknown;
|
|
1155
|
+
try { cards = JSON.parse(raw); } catch { return console.log(raw); }
|
|
1156
|
+
if (!Array.isArray(cards)) return console.log(raw);
|
|
1157
|
+
return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
|
|
1158
|
+
}
|
|
1145
1159
|
if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
|
|
1146
1160
|
if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
|
|
1147
1161
|
return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
@@ -1295,6 +1309,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1295
1309
|
if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
|
|
1296
1310
|
return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
|
|
1297
1311
|
}
|
|
1312
|
+
if (sub === "metrics") {
|
|
1313
|
+
const flags = takeFlags(args.slice(1), ["--since"], []);
|
|
1314
|
+
if (flags.rest.length) fail("usage: ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]");
|
|
1315
|
+
return console.log(await taskOp("hub_sdlc_metrics", { ...(flags.one["--since"] !== undefined ? { since: flags.one["--since"] } : {}) }));
|
|
1316
|
+
}
|
|
1298
1317
|
if (sub === "plan-review") {
|
|
1299
1318
|
const revision = Number(args[2]);
|
|
1300
1319
|
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
|
|
@@ -1504,7 +1523,31 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1504
1523
|
} finally { hub.close(); }
|
|
1505
1524
|
},
|
|
1506
1525
|
settings: async () => {
|
|
1507
|
-
|
|
1526
|
+
// #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
|
|
1527
|
+
if (args[0] === "edit" || args[0] === "backups") {
|
|
1528
|
+
// A command line the parser refuses never reaches the hub, not even as a connection.
|
|
1529
|
+
const early = args[0] === "backups" ? (args.length > 2 || (args[1] !== undefined && args[1] !== "--json") ? SETTINGS_EDIT_USAGE : undefined) : (() => { const parsed = parseSettingsEditArgs(args.slice(1)); return typeof parsed === "string" ? parsed : undefined; })();
|
|
1530
|
+
if (early) fail(early);
|
|
1531
|
+
const hub = await connect();
|
|
1532
|
+
try {
|
|
1533
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
1534
|
+
const usage = await runSettingsEdit(args, {
|
|
1535
|
+
request: async message => {
|
|
1536
|
+
const reply = await hub.request(message, 35_000);
|
|
1537
|
+
if (reply.ok !== true) {
|
|
1538
|
+
const error = String(reply.error ?? "config edit refused");
|
|
1539
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "config_edit/i.test(error) ? `${error}; upgrade and restart the running hub to use ahub settings edit` : error);
|
|
1540
|
+
}
|
|
1541
|
+
return reply;
|
|
1542
|
+
},
|
|
1543
|
+
ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
|
|
1544
|
+
print: line => console.log(terminalText(line)),
|
|
1545
|
+
}).finally(() => rl?.close());
|
|
1546
|
+
if (usage) fail(usage);
|
|
1547
|
+
} finally { hub.close(); }
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload | edit <file> ... | backups";
|
|
1508
1551
|
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1509
1552
|
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1510
1553
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
@@ -2249,7 +2292,7 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2249
2292
|
const runConfigured = configuredCommand ? async () => {
|
|
2250
2293
|
const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
|
|
2251
2294
|
if (!selection.preview) assertLifecycleAvailable();
|
|
2252
|
-
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2295
|
+
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2253
2296
|
} : undefined;
|
|
2254
2297
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
2255
2298
|
try {
|
package/src/cli/output.ts
CHANGED
|
@@ -248,6 +248,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
248
248
|
if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
249
249
|
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
250
|
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
251
|
+
if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
|
|
252
|
+
if (p.ownerIsolationNote) out.push(...detail("owner isolation", p.ownerIsolationNote, columns, "attention"));
|
|
251
253
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
252
254
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
253
255
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
@@ -268,10 +270,82 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
268
270
|
if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
|
|
269
271
|
return out;
|
|
270
272
|
}
|
|
271
|
-
|
|
273
|
+
// #724: short next-step codes for the compact board, keyed on the blocker texts of src/hub/sdlc.ts; the full advice stays in --full and ahub task show.
|
|
274
|
+
const STAGE_CODES: [string, string][] = [["issue identity missing", "issue?"], ["PR identity missing", "PR?"], ["PR is not open", "PR closed"], ["current PR evidence unknown", "PR state?"],
|
|
275
|
+
["required check", "CI?"], ["local gate", "gate?"], ["current head has not been reviewed", "review?"], ["GitHub review approval", "approval?"], ["review requests", "requests?"],
|
|
276
|
+
["review threads", "threads?"], ["open important review findings", "findings"], ["person may inspect merge readiness", "ready"]];
|
|
277
|
+
export function stageCode(next: string): string {
|
|
278
|
+
const known = STAGE_CODES.find(([prefix]) => next.startsWith(prefix));
|
|
279
|
+
return known ? known[1] : width(next) <= 12 ? next : characters(next).slice(0, 9).join("") + "...";
|
|
280
|
+
}
|
|
281
|
+
const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
|
|
282
|
+
const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
|
|
283
|
+
/**
|
|
284
|
+
* #399: `ahub backlog list` as a table: one line per card, by rank (unranked last) then id. With a width, TITLE goes last and is
|
|
285
|
+
* cut like the compact board, and AGE then CLASS give way first on a narrow terminal; without one (piped) or with full, titles are whole.
|
|
286
|
+
*/
|
|
287
|
+
export function renderBacklog(cards: any[], columns?: number, now = Date.now(), full = false): Span[][] {
|
|
288
|
+
if (!cards.length) return [[span("no backlog cards")]];
|
|
289
|
+
const sorted = [...cards].sort((a, b) => (typeof a.rank === "number" ? a.rank : Infinity) - (typeof b.rank === "number" ? b.rank : Infinity) || a.id - b.id);
|
|
290
|
+
let names = ["RANK", "ID", "PRI", "RESERVED", "AFTER", "CLASS", "AGE", "TITLE"];
|
|
291
|
+
let table: OutputCell[][] = sorted.map(card => {
|
|
292
|
+
const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
|
|
293
|
+
return [span(typeof card.rank === "number" ? card.rank : "-", "muted", true), span(`#${card.id}`, "info", true), span(typeof card.priority === "number" ? `P${card.priority}` : "-", undefined, true),
|
|
294
|
+
span(card.reserved ?? "-", peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
|
|
295
|
+
span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
|
|
296
|
+
});
|
|
297
|
+
if (!full && columns !== undefined) {
|
|
298
|
+
const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
|
|
299
|
+
const room = () => columns - names.reduce((sum, name, i) => name === "TITLE" ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1);
|
|
300
|
+
const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
|
|
301
|
+
for (const name of ["AGE", "CLASS", "PRI"]) if (room() < want) drop(name);
|
|
302
|
+
const titleRoom = Math.max(5, room());
|
|
303
|
+
table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
|
|
304
|
+
}
|
|
305
|
+
const owners = new Map<string, number>();
|
|
306
|
+
for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
|
|
307
|
+
const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ")}`;
|
|
308
|
+
return [...rows(names, table, columns), [], ...lines(summary, columns)];
|
|
309
|
+
}
|
|
310
|
+
/** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
|
|
311
|
+
function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
|
|
312
|
+
const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
|
|
313
|
+
let table = tasks.map((task, i) => {
|
|
314
|
+
const row = data[i]!, deps: number[] = task.deps ?? [];
|
|
315
|
+
const after = deps.length ? `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}` : "-";
|
|
316
|
+
const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
|
|
317
|
+
const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
318
|
+
const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
319
|
+
return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
|
|
320
|
+
});
|
|
321
|
+
let names = [...head];
|
|
322
|
+
const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
|
|
323
|
+
for (const name of [...names]) if (name !== "TITLE" && table.every(row => flat(row[names.indexOf(name)]?.text) === "-")) drop(name);
|
|
324
|
+
const room = () => { const t = names.indexOf("TITLE"); return columns - names.reduce((sum, name, i) => i === t ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1); };
|
|
325
|
+
const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
|
|
326
|
+
for (const name of ["REVIEWER", "CLASS", "AGE", "STAGE"]) if (room() < want) {
|
|
327
|
+
// STAGE is never dropped: it falls back to the meter and the code, the stage name being the meter's position.
|
|
328
|
+
if (name !== "STAGE") drop(name);
|
|
329
|
+
else { const at = names.indexOf("STAGE"); if (at >= 0) table = table.map(row => row.map((cell, i) => i === at ? { ...cell, text: (cell as OutputCell & { short?: string }).short ?? cell.text } : cell)); }
|
|
330
|
+
}
|
|
331
|
+
// Still too narrow (a long instance id as OWNER): AFTER gives way, then OWNER is cut, so the row never becomes linked bands.
|
|
332
|
+
if (room() < 8) drop("AFTER");
|
|
333
|
+
const owner = names.indexOf("OWNER");
|
|
334
|
+
if (room() < 8 && owner >= 0) { const cut = Math.max(5, Math.max(...table.map(row => width(flat(row[owner]!.text)))) - (8 - room())); table = table.map(row => row.map((cell, i) => i === owner ? { ...cell, text: ellipsis(flat(cell.text), cut) } : cell)); }
|
|
335
|
+
const titleRoom = Math.max(5, room());
|
|
336
|
+
// A PII row keeps its whole "ahub task show" hint when 8 columns of title remain; the cut title is never wrapped.
|
|
337
|
+
table = table.map((row, i) => { const pii = ` (ahub task show ${tasks[i].id})`, hint = tasks[i].signals?.includes("pii") && titleRoom - width(pii) >= 8 ? pii : ""; return [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, Math.max(4, titleRoom - width(hint))) + hint }]; });
|
|
338
|
+
return rows(names, table, columns);
|
|
339
|
+
}
|
|
340
|
+
export function renderBoard(tasks: any[], columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
|
|
272
341
|
if (!tasks.length) return [[span("no tasks")]];
|
|
273
342
|
const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
|
|
274
343
|
const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
|
|
344
|
+
const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
|
|
345
|
+
if (!full && columns !== undefined) {
|
|
346
|
+
const data = tasks.map(task => { const row = cells(s, task, false, now, stages); row[2] = span(row[2]!.text, peerTone(task.owner ?? "")); row[3] = span(row[3]!.text, peerTone(task.reviewer ?? "")); return row; });
|
|
347
|
+
return [...compactBoard(tasks, data, columns, allTasks !== null), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns), ...(allTasks !== null ? lines(BOARD_LEGEND, columns, "muted") : [])];
|
|
348
|
+
}
|
|
275
349
|
const data = tasks.map(task => {
|
|
276
350
|
const row = cells(s, task, false, now, stages);
|
|
277
351
|
row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
|
|
@@ -280,7 +354,6 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _f
|
|
|
280
354
|
if (allTasks === null) row.pop();
|
|
281
355
|
return row;
|
|
282
356
|
});
|
|
283
|
-
const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
|
|
284
357
|
return [...rows(allTasks === null ? TABLES[3]!.slice(0, -1) : [...TABLES[3]!], data, columns), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns)];
|
|
285
358
|
}
|
|
286
359
|
export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
|
package/src/cli/reset.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
|
11
11
|
/** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
|
|
12
12
|
export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
|
|
13
13
|
/** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
|
|
14
|
-
const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
|
|
14
|
+
const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context|task-sessions)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
|
|
15
15
|
/** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
|
|
16
16
|
export const MANIFEST = ["status.json", "control-token", "hub.pid"];
|
|
17
17
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** #689: `ahub settings edit` and `ahub settings backups`. Parsing and rendering only; the hub validates, previews and writes. */
|
|
2
|
+
export interface SettingsEditArgs { file: "config.local.json" | "sdlc.toml"; set: { path: string[]; value: unknown }[]; reason?: string; preview: boolean; yes: boolean }
|
|
3
|
+
export const SETTINGS_EDIT_USAGE = "usage: ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | ahub settings backups [--json]";
|
|
4
|
+
|
|
5
|
+
/** The request a command line asks for, or the usage line. Values are JSON, so a string needs its quotes. */
|
|
6
|
+
export function parseSettingsEditArgs(argv: readonly string[]): SettingsEditArgs | string {
|
|
7
|
+
const [file, ...rest] = argv;
|
|
8
|
+
if (file !== "config.local.json" && file !== "sdlc.toml") return SETTINGS_EDIT_USAGE;
|
|
9
|
+
const set: SettingsEditArgs["set"] = [];
|
|
10
|
+
let reason: string | undefined, preview = false, yes = false;
|
|
11
|
+
const path = (text: string | undefined): string[] | undefined => {
|
|
12
|
+
if (!text || text.startsWith(".") || text.endsWith(".") || text.includes("..")) return undefined;
|
|
13
|
+
return text.split(".");
|
|
14
|
+
};
|
|
15
|
+
for (let index = 0; index < rest.length; index++) {
|
|
16
|
+
const word = rest[index]!;
|
|
17
|
+
if (word === "--preview") preview = true;
|
|
18
|
+
else if (word === "--yes") yes = true;
|
|
19
|
+
else if (word === "--reason") { reason = rest[++index]; if (reason === undefined) return SETTINGS_EDIT_USAGE; }
|
|
20
|
+
else if (word === "set") {
|
|
21
|
+
const target = path(rest[++index]), text = rest[++index];
|
|
22
|
+
if (!target || text === undefined) return SETTINGS_EDIT_USAGE;
|
|
23
|
+
try { set.push({ path: target, value: JSON.parse(text) }); } catch { return SETTINGS_EDIT_USAGE; }
|
|
24
|
+
} else if (word === "remove") {
|
|
25
|
+
const target = path(rest[++index]);
|
|
26
|
+
if (!target) return SETTINGS_EDIT_USAGE;
|
|
27
|
+
set.push({ path: target, value: null });
|
|
28
|
+
} else return SETTINGS_EDIT_USAGE;
|
|
29
|
+
}
|
|
30
|
+
if (!set.length) return SETTINGS_EDIT_USAGE;
|
|
31
|
+
return { file, set, ...(reason !== undefined ? { reason } : {}), preview, yes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a person reads before confirming: the diff lines, the warning of a tier 3 key and the word to type. */
|
|
35
|
+
export function renderEditPreview(reply: { ok?: unknown; lines?: unknown; warning?: unknown; confirm?: unknown }): string[] {
|
|
36
|
+
const lines = Array.isArray(reply.lines) ? reply.lines.map(line => String(line)) : [];
|
|
37
|
+
if (typeof reply.warning === "string") lines.push(`warning: ${reply.warning}`);
|
|
38
|
+
if (typeof reply.confirm === "string") lines.push(`type ${reply.confirm} to confirm`);
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SettingsEditPorts {
|
|
43
|
+
request: (message: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
44
|
+
/** Asks one line on a terminal; null when there is none. */
|
|
45
|
+
ask: (prompt: string) => Promise<string | null>;
|
|
46
|
+
print: (line: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The whole command, with its ports injected. The person's terminal is the only place a tier 3 key can be changed, and only by
|
|
50
|
+
* typing the key's name: `--yes` confirms an ordinary structural edit and never a tier 3 one.
|
|
51
|
+
*/
|
|
52
|
+
export async function runSettingsEdit(argv: readonly string[], ports: SettingsEditPorts): Promise<string | undefined> {
|
|
53
|
+
const { request, ask, print } = ports;
|
|
54
|
+
if (argv[0] === "backups") {
|
|
55
|
+
if (argv.length > 2 || (argv[1] !== undefined && argv[1] !== "--json")) return SETTINGS_EDIT_USAGE;
|
|
56
|
+
const view = await request({ t: "config_edit_get" });
|
|
57
|
+
const backups = Array.isArray(view.backups) ? view.backups as { name: string; file: string; at: number; bytes: number }[] : [];
|
|
58
|
+
if (argv[1] === "--json") print(JSON.stringify(backups, null, 2));
|
|
59
|
+
else if (!backups.length) print("no config backups yet");
|
|
60
|
+
else for (const item of backups) print(`${new Date(item.at).toISOString()} ${item.file} ${item.bytes} bytes ${item.name}`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const parsed = parseSettingsEditArgs(argv[0] === "edit" ? argv.slice(1) : argv);
|
|
64
|
+
if (typeof parsed === "string") return parsed;
|
|
65
|
+
const view = await request({ t: "config_edit_get" });
|
|
66
|
+
const revisions = view.revisions as Record<string, string> | undefined;
|
|
67
|
+
const revision = revisions?.[parsed.file];
|
|
68
|
+
if (typeof revision !== "string") return "the running hub does not report config revisions: upgrade and restart it to use ahub settings edit";
|
|
69
|
+
const body = { file: parsed.file, revision, set: parsed.set };
|
|
70
|
+
const preview = await request({ t: "config_edit_preview", edit: body });
|
|
71
|
+
for (const line of renderEditPreview(preview)) print(line);
|
|
72
|
+
if (parsed.preview) { print("preview only; nothing was changed"); return undefined; }
|
|
73
|
+
const typed = typeof preview.confirm === "string" ? preview.confirm : undefined;
|
|
74
|
+
let confirm: string | undefined;
|
|
75
|
+
if (typed) {
|
|
76
|
+
const answer = await ask(`Type ${typed} to confirm: `);
|
|
77
|
+
if (answer === null) return "not a terminal: a key that runs code or locates secrets is confirmed by typing its name; nothing was changed";
|
|
78
|
+
if (answer.trim() !== typed) { print("nothing was changed"); return undefined; }
|
|
79
|
+
confirm = typed;
|
|
80
|
+
} else if (!parsed.yes) {
|
|
81
|
+
const answer = await ask("Write this change? [y/N] ");
|
|
82
|
+
if (answer === null) return "not a terminal: nothing was changed; rerun with --yes after reviewing the preview";
|
|
83
|
+
if (!/^(y|yes)$/i.test(answer.trim())) { print("nothing was changed"); return undefined; }
|
|
84
|
+
}
|
|
85
|
+
const result = await request({ t: "config_edit_apply", edit: body, ...(confirm ? { confirm } : {}), ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) });
|
|
86
|
+
print(String(result.text ?? "config written"));
|
|
87
|
+
if (Array.isArray(result.restartPending) && result.restartPending.length) print(`restart pending: ${result.restartPending.join(", ")}; restart the hub (ahub restart) to apply`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -328,7 +328,7 @@ export function recordPath(stateDir: string): string {
|
|
|
328
328
|
return join(stateDir, RECORD_FILE);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
/**
|
|
331
|
+
/** Resolve declared terminal roots without treating a launch candidate as daemon ownership proof. */
|
|
332
332
|
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
333
333
|
const builtin = definitionOf(peer);
|
|
334
334
|
if (builtin?.session.launchRecord) return projectRoot;
|
|
@@ -336,11 +336,6 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
336
336
|
const definition = catalog.definitionOf(peer, declarations);
|
|
337
337
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
338
338
|
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
339
|
-
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
340
|
-
if (definition.transport === "claude-channel") {
|
|
341
|
-
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
342
|
-
return projectRoot;
|
|
343
|
-
}
|
|
344
339
|
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
|
|
345
340
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
346
341
|
return expected;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
|
|
2
|
+
import { actionApprovalText, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
|
+
import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the always-ask policy (#636) meets the two surfaces that see a tool call before it runs: the Claude PreToolUse hook and ACP
|
|
7
|
+
* `session/request_permission`. The classifier (`action-class.ts`) and the budget ledger (`action-policy.ts`) decide; this file only
|
|
8
|
+
* connects them, and only ever adds an approval: an empty result keeps the native permission policy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One native tool call as an adapter saw it: every name it is known by, the ACP tool kind when reported, and its arguments. */
|
|
12
|
+
export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
|
|
13
|
+
|
|
14
|
+
/** The classes of one call. ACP names a shell call `execute` by kind whatever its tool is called, so that kind is also read as a shell. */
|
|
15
|
+
export function classifyCall(call: ActionCall, root: string): ActionClass[] {
|
|
16
|
+
const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
|
|
17
|
+
if (call.kind === "execute") names.add("bash");
|
|
18
|
+
const found = new Set<ActionClass>();
|
|
19
|
+
for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
|
|
20
|
+
return ACTION_CLASSES.filter(key => found.has(key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The classes the Claude PreToolUse hook must `ask` about, in every permission mode. The budget never relaxes it: Claude's own dialog
|
|
25
|
+
* cannot carry a class-budget choice, so every classed Claude call asks (a count of zero approved calls, always). A handle-less request
|
|
26
|
+
* selects the classes without touching any session ledger.
|
|
27
|
+
*/
|
|
28
|
+
export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
|
|
29
|
+
const classes = classifyAction(tool, input, root);
|
|
30
|
+
if (!classes.length) return [];
|
|
31
|
+
const decision = budgets.request(undefined, classes);
|
|
32
|
+
return decision.kind === "ask" ? decision.classes : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The command line an approval shows: the shell command when there is one, else the arguments. Bounded by the caller's screen. */
|
|
36
|
+
export function commandText(input: unknown): string {
|
|
37
|
+
let value: unknown = input;
|
|
38
|
+
if (typeof input === "string") { try { value = JSON.parse(input) as unknown; } catch { return input; } }
|
|
39
|
+
if (typeof value === "string") return value;
|
|
40
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41
|
+
const record = value as Record<string, unknown>, command = record.command ?? record.cmd;
|
|
42
|
+
if (typeof command === "string") return command;
|
|
43
|
+
if (Array.isArray(command) && command.every(word => typeof word === "string")) return (command as string[]).join(" ");
|
|
44
|
+
if (Array.isArray(record.args) && record.args.every(word => typeof word === "string")) return ["git", ...(record.args as string[])].join(" ");
|
|
45
|
+
}
|
|
46
|
+
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ActionGatePorts<T> {
|
|
50
|
+
budgets: ActionBudgets;
|
|
51
|
+
policy(): ActionPolicyConfig | undefined;
|
|
52
|
+
/** The peer's execution root: a delete outside it is classed. */
|
|
53
|
+
root(peer: string): string;
|
|
54
|
+
/** The task the peer is working on, for the approval line. */
|
|
55
|
+
task(peer: string): T | undefined;
|
|
56
|
+
/** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
|
|
57
|
+
publicView(task: T): Record<string, unknown>;
|
|
58
|
+
/** A private turn or an open PII task: the command is withheld. */
|
|
59
|
+
isPrivate(peer: string): boolean;
|
|
60
|
+
/** The PII screen for text (`Tasks.nameable`). */
|
|
61
|
+
nameable(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ACP gate of one peer. It returns a verdict only when a person must be asked: a classed call with no session budget left. A call
|
|
66
|
+
* within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
|
|
67
|
+
* spends here, once per request).
|
|
68
|
+
*/
|
|
69
|
+
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string) => ActionGateVerdict | undefined {
|
|
70
|
+
return (call, session) => {
|
|
71
|
+
const classes = classifyCall(call, ports.root(peer));
|
|
72
|
+
if (!classes.length) return undefined;
|
|
73
|
+
const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
|
|
74
|
+
if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
|
|
75
|
+
const title = actionApprovalText(decision.classes, commandText(call.input), ports.task(peer), {
|
|
76
|
+
publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined });
|
|
77
|
+
const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
|
|
78
|
+
// A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
|
|
79
|
+
if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
|
|
80
|
+
return { title, budget: { label: decision.classes.map(key => `${key} x${limits[key]}`).join(", "), approve: () => ports.budgets.approve(challenge) } };
|
|
81
|
+
};
|
|
82
|
+
}
|