@staix/agent-hub 0.14.0 → 0.16.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 +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- 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/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -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-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- 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 +17 -5
- 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-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/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -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 +614 -104
- 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/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import type { DeliverableRow } from "../hub/tasks.ts";
|
|
3
|
+
import { kimiSourceRow } from "../hub/budget-notes.ts";
|
|
2
4
|
import { hubGitSync, hubGitEnv } from "../hub/git.ts";
|
|
3
5
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
|
|
4
6
|
import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
|
|
@@ -18,6 +20,7 @@ import { matchesPolicy } from "../hub/path-policy.ts";
|
|
|
18
20
|
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
19
21
|
import { TextProjections } from "../hub/text-projection.ts";
|
|
20
22
|
import { terminalText } from "./console-state.ts";
|
|
23
|
+
import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
|
|
21
24
|
import { petView, readPet, renderPet } from "./pet.ts";
|
|
22
25
|
import { startPetWindow, closePetWindow } from "../pet/window.ts";
|
|
23
26
|
import { loadPetConfig, savePetPalette } from "./pet-config.ts";
|
|
@@ -36,7 +39,7 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
36
39
|
import { randomUUID } from "node:crypto";
|
|
37
40
|
import { homedir } from "node:os";
|
|
38
41
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
39
|
-
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
42
|
+
import { retryLaunchConnect, ControlClient, readControl } from "../hub/control-client.ts";
|
|
40
43
|
import { loadConfig } from "../hub/daemon.ts";
|
|
41
44
|
import { factsHook } from "./facts-hook.ts";
|
|
42
45
|
import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
|
|
@@ -45,7 +48,6 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
|
|
|
45
48
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
46
49
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
47
50
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
48
|
-
import { MemoryClient } from "../memory/client.ts";
|
|
49
51
|
import { init, planInit } from "./init.ts";
|
|
50
52
|
import { launcherPreview } from "./preview.ts";
|
|
51
53
|
import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
@@ -74,8 +76,9 @@ import { parseSince, readEvents } from "../hub/events.ts";
|
|
|
74
76
|
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
|
|
75
77
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
76
78
|
import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
|
|
77
|
-
import { goalCommand, memoryCommand } from "./memory.ts";
|
|
78
|
-
import {
|
|
79
|
+
import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
|
|
80
|
+
import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
|
|
81
|
+
import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
|
|
79
82
|
import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
|
|
80
83
|
import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
81
84
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
@@ -89,7 +92,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
|
|
|
89
92
|
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
90
93
|
import { runConsole } from "./console.ts";
|
|
91
94
|
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";
|
|
95
|
+
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
96
|
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
94
97
|
import { renderTailEvent } from "./tail-render.ts";
|
|
95
98
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -274,13 +277,15 @@ function printOutput(lines: Span[][], color: boolean): void {
|
|
|
274
277
|
}
|
|
275
278
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
276
279
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
277
|
-
const connect = async () => {
|
|
280
|
+
const connect = async (timeoutMs = 3000) => {
|
|
278
281
|
assertDelegatedAuditConsumer();
|
|
279
282
|
if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
|
|
280
283
|
return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
|
|
281
|
-
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
|
|
284
|
+
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) }, timeoutMs);
|
|
282
285
|
};
|
|
283
286
|
|
|
287
|
+
const connectLaunch = () => retryLaunchConnect(connect, () => console.error("hub busy, retrying"));
|
|
288
|
+
|
|
284
289
|
function registeredProject(): Project {
|
|
285
290
|
const registry = new Registry();
|
|
286
291
|
try { return registry.register(cwd, stateDir); }
|
|
@@ -943,7 +948,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
943
948
|
// Preserve the original guard before native effects; previews remain read-only and precede this guard.
|
|
944
949
|
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
945
950
|
const nativeConnect = async () => {
|
|
946
|
-
const hub = await
|
|
951
|
+
const hub = await connectLaunch();
|
|
947
952
|
try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
|
|
948
953
|
catch (error) { hub.close(); throw error; }
|
|
949
954
|
};
|
|
@@ -955,7 +960,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
955
960
|
projectConfig, connect: nativeConnect, fail,
|
|
956
961
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
957
962
|
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
958
|
-
helpers: {
|
|
963
|
+
helpers: { resolveExecutionCwd: peer => {
|
|
964
|
+
const current = readInstanceRoleConfig(cwd);
|
|
965
|
+
return resolveInstanceExecutionCwd(cwd, peer, current.instances, effectiveInstanceRoles(peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
966
|
+
}, launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
|
|
959
967
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
960
968
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
961
969
|
buildKimiTuiLaunch, startModeOf },
|
|
@@ -1134,14 +1142,22 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1134
1142
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
1135
1143
|
hub.close();
|
|
1136
1144
|
if (!res.ok) fail(res.error);
|
|
1137
|
-
const data = { budget: res.budget, gate: res.gate };
|
|
1145
|
+
const data = { budget: res.budget, ...(res.peers ? { peers: res.peers } : {}), gate: res.gate };
|
|
1138
1146
|
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1139
1147
|
printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
|
|
1140
1148
|
},
|
|
1141
1149
|
|
|
1142
1150
|
backlog: async () => {
|
|
1143
1151
|
const [sub, id, revision, value, ...reason] = args;
|
|
1144
|
-
if (sub === "list" &&
|
|
1152
|
+
if (sub === "list" && outputArguments().length === 1) {
|
|
1153
|
+
// #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
|
|
1154
|
+
const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
|
|
1155
|
+
if (options.json) return console.log(raw);
|
|
1156
|
+
let cards: unknown;
|
|
1157
|
+
try { cards = JSON.parse(raw); } catch { return console.log(raw); }
|
|
1158
|
+
if (!Array.isArray(cards)) return console.log(raw);
|
|
1159
|
+
return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
|
|
1160
|
+
}
|
|
1145
1161
|
if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
|
|
1146
1162
|
if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
|
|
1147
1163
|
return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
@@ -1181,6 +1197,27 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1181
1197
|
console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
|
|
1182
1198
|
},
|
|
1183
1199
|
|
|
1200
|
+
deliverables: async () => {
|
|
1201
|
+
// #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). Person only: the daemon refuses an agent.
|
|
1202
|
+
const options = outputOptions();
|
|
1203
|
+
const { one, rest } = takeFlags(args, ["--task"], []);
|
|
1204
|
+
const approved = rest.includes("--approved"), check = rest.includes("--check");
|
|
1205
|
+
const extra = rest.filter(arg => !["--json", "--full", "--approved", "--check"].includes(arg) && !arg.startsWith("--color="));
|
|
1206
|
+
const task = one["--task"] === undefined ? undefined : Number(one["--task"]);
|
|
1207
|
+
// takeFlags keeps the last of a repeated flag: for a command that selects a task that would silently drop one of two named tasks.
|
|
1208
|
+
if (args.filter(arg => arg === "--task").length > 1) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never] (--task takes one id)");
|
|
1209
|
+
if (extra.length || (one["--task"] !== undefined && !/^[1-9]\d*$/.test(one["--task"])) || (task !== undefined && !Number.isSafeInteger(task))) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]");
|
|
1210
|
+
const rows = JSON.parse(await taskOp("deliverables", { ...(task === undefined ? {} : { task }), ...(approved ? { approved: true } : {}), ...(check ? { check: true } : {}) })) as DeliverableRow[];
|
|
1211
|
+
if (options.json) return console.log(JSON.stringify(rows, null, 2));
|
|
1212
|
+
if (!rows.length) return console.log(task === undefined ? `no ${approved ? "approved " : ""}deliverables on the board` : `task #${task} has no ${approved ? "approved " : ""}deliverables`);
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
const flag = row.checked ? ({ changed: " (changed since review)", missing: " (a file is missing)", unknown: " (could not be measured)", unchanged: " (unchanged)" } as const)[row.result ?? "unknown"] : "";
|
|
1215
|
+
console.log(`${row.title} [${row.state}${flag}]`); // `title` is the hub's publicTitle: it already starts with `#<id>`
|
|
1216
|
+
for (const file of row.files) console.log(` ${file.id} ${file.path ?? "(private)"} ${file.format}${file.version ? ` ${file.version}` : ""} ${file.bytes} B sha256 ${file.sha256}${file.state ? ` ${file.state}` : ""}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (!check) console.log("(hashes are the board's records; add --check to measure the files now)");
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1184
1221
|
board: async () => {
|
|
1185
1222
|
if (args[0] === "import") {
|
|
1186
1223
|
if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
|
|
@@ -1366,8 +1403,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1366
1403
|
if (sub === "assign") {
|
|
1367
1404
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
1368
1405
|
const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
|
|
1369
|
-
if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
|
|
1370
|
-
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role
|
|
1406
|
+
if (positional.length !== 2 || !["owner", "reviewer", "planner"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]");
|
|
1407
|
+
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role !== "owner" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
|
|
1371
1408
|
}
|
|
1372
1409
|
if (sub === "unassign") {
|
|
1373
1410
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
@@ -1509,7 +1546,31 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1509
1546
|
} finally { hub.close(); }
|
|
1510
1547
|
},
|
|
1511
1548
|
settings: async () => {
|
|
1512
|
-
|
|
1549
|
+
// #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
|
|
1550
|
+
if (args[0] === "edit" || args[0] === "backups") {
|
|
1551
|
+
// A command line the parser refuses never reaches the hub, not even as a connection.
|
|
1552
|
+
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; })();
|
|
1553
|
+
if (early) fail(early);
|
|
1554
|
+
const hub = await connect();
|
|
1555
|
+
try {
|
|
1556
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
1557
|
+
const usage = await runSettingsEdit(args, {
|
|
1558
|
+
request: async message => {
|
|
1559
|
+
const reply = await hub.request(message, 35_000);
|
|
1560
|
+
if (reply.ok !== true) {
|
|
1561
|
+
const error = String(reply.error ?? "config edit refused");
|
|
1562
|
+
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);
|
|
1563
|
+
}
|
|
1564
|
+
return reply;
|
|
1565
|
+
},
|
|
1566
|
+
ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
|
|
1567
|
+
print: line => console.log(terminalText(line)),
|
|
1568
|
+
}).finally(() => rl?.close());
|
|
1569
|
+
if (usage) fail(usage);
|
|
1570
|
+
} finally { hub.close(); }
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload | edit <file> ... | backups";
|
|
1513
1574
|
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1514
1575
|
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1515
1576
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
@@ -2063,6 +2124,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2063
2124
|
|
|
2064
2125
|
doctor: async () => {
|
|
2065
2126
|
const options = outputOptions();
|
|
2127
|
+
if (args.includes("--stale-anchors")) {
|
|
2128
|
+
if (args.some(arg => arg !== "--stale-anchors" && arg !== "--json")) fail("usage: ahub doctor --stale-anchors [--json]");
|
|
2129
|
+
const report = await memoryStaleAnchorsCommand(cwd, { stateDir });
|
|
2130
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2066
2133
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
2067
2134
|
const version = (bin: string) => {
|
|
2068
2135
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
@@ -2090,6 +2157,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2090
2157
|
const { status } = await hub.request({ t: "status" });
|
|
2091
2158
|
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
2092
2159
|
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
2160
|
+
// #285 AC10: where Kimi's number comes from, and whether the hub can trust it right now. An older hub answers without `kimiSource`: no row, not a failure.
|
|
2161
|
+
const quota = await hub.request({ t: "budget" });
|
|
2162
|
+
const kimiRow = quota.ok ? kimiSourceRow(quota.kimiSource) : undefined;
|
|
2163
|
+
if (kimiRow) row(kimiRow.ok, "kimi usage source", kimiRow.detail);
|
|
2093
2164
|
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
2094
2165
|
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
2095
2166
|
const check = nativeDoctorRecoveryIdentity(peer);
|
|
@@ -2144,9 +2215,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2144
2215
|
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
2145
2216
|
}
|
|
2146
2217
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
row(
|
|
2218
|
+
let memoryLibraryReady = false;
|
|
2219
|
+
try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
|
|
2220
|
+
row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
|
|
2150
2221
|
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
2151
2222
|
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2152
2223
|
for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
|
|
@@ -2222,7 +2293,8 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2222
2293
|
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
2223
2294
|
const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
|
|
2224
2295
|
const role = one["--role"] ?? "owner";
|
|
2225
|
-
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
|
|
2296
|
+
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer", "planner"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] | escalate <id>");
|
|
2297
|
+
if (role === "planner") fail("only the person may name a task planner");
|
|
2226
2298
|
input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
|
|
2227
2299
|
} else if (cmd === "pause" || cmd === "resume") {
|
|
2228
2300
|
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|
|
@@ -2254,7 +2326,7 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2254
2326
|
const runConfigured = configuredCommand ? async () => {
|
|
2255
2327
|
const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
|
|
2256
2328
|
if (!selection.preview) assertLifecycleAvailable();
|
|
2257
|
-
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2329
|
+
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2258
2330
|
} : undefined;
|
|
2259
2331
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
2260
2332
|
try {
|
package/src/cli/memory.ts
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import { TaskRepositories } from "../hub/task-repository.ts";
|
|
2
|
+
import type { TaskPlan, TaskRefs } from "../hub/board.ts";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { lstatSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CodeFacts } from "../hub/code-facts.ts";
|
|
7
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
8
|
+
import { hubGitEnv, hubGitSync } from "../hub/git.ts";
|
|
9
|
+
import { stateDirFor } from "../hub/control-client.ts";
|
|
10
|
+
import { isDenied } from "../local/deny.ts";
|
|
11
|
+
import { foldSegment } from "../hub/permission-mode.ts";
|
|
12
|
+
import { matchesPolicyLexically } from "../hub/path-policy.ts";
|
|
13
|
+
import { memoryAnchorTaskIdentity, memoryStaleAnchors, unavailableMemoryStaleAnchors, type MemoryStaleAnchorReport, type MemoryTaskPrivacy } from "../hub/memory-stale-anchors.ts";
|
|
14
|
+
import { MEMORY_SYMBOL_START_BUDGET_MS } from "../hub/memory-symbols.ts";
|
|
1
15
|
import { detectCliIdentity } from "./identity.ts";
|
|
2
16
|
import { createStalenessProbe } from "../hub/memory-staleness.ts";
|
|
3
|
-
import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
|
|
17
|
+
import { looksSecret, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
|
|
4
18
|
import type { SyncOptions } from "@staix/agent-mem";
|
|
5
|
-
import {
|
|
19
|
+
import { memoryUsage, goalUsage, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
|
|
20
|
+
|
|
21
|
+
/** The hub's command names in agent-mem usage and refusal text (agent-mem 0.7.0 defaults to `amem`). */
|
|
22
|
+
const AHUB_NAMES = { memory: "ahub memory", goal: "ahub goal" };
|
|
6
23
|
import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
|
|
7
24
|
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
8
25
|
import { loadConfig } from "../hub/daemon.ts";
|
|
9
|
-
import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
26
|
+
import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MemoryProfileBinding, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
10
27
|
|
|
11
|
-
export
|
|
12
|
-
export const MEMORY_USAGE = `${
|
|
28
|
+
export const GOAL_USAGE = goalUsage(AHUB_NAMES.goal);
|
|
29
|
+
export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | profile-check [--json]`;
|
|
13
30
|
|
|
14
31
|
const fail = (message: string): never => { throw new Error(message); };
|
|
15
32
|
/** The project's config, or none when there is no valid one (reading memory needs none). */
|
|
@@ -75,7 +92,7 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
75
92
|
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
|
|
76
93
|
try {
|
|
77
94
|
context.binding.assertCurrent();
|
|
78
|
-
const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
95
|
+
const text = await rawMemoryCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
|
|
79
96
|
readable: notes => {
|
|
80
97
|
const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
|
|
81
98
|
if (args[0] !== "show" || args.includes("--json")) return visible;
|
|
@@ -99,9 +116,104 @@ export async function goalCommand(args: string[], cwd: string, opts: { store?: M
|
|
|
99
116
|
const context = commandStores(opts.store, { project }, cwd);
|
|
100
117
|
try {
|
|
101
118
|
context.binding.assertCurrent();
|
|
102
|
-
const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
119
|
+
const text = await rawGoalCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
|
|
103
120
|
clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
|
|
104
121
|
});
|
|
105
122
|
return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
|
|
106
123
|
} finally { if (context.owned) context.store.close(); }
|
|
107
124
|
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
interface StaleAnchorCommandOptions {
|
|
128
|
+
store?: MemoryStore;
|
|
129
|
+
facts?: Pick<CodeFacts, "exists">;
|
|
130
|
+
/** Trusted host test seam; never read from CLI arguments or project configuration. */
|
|
131
|
+
createFacts?: () => Pick<CodeFacts, "exists" | "close">;
|
|
132
|
+
stateDir?: string;
|
|
133
|
+
head?: () => string | undefined;
|
|
134
|
+
taskPrivacy?: (key: string) => MemoryTaskPrivacy;
|
|
135
|
+
now?: () => number;
|
|
136
|
+
}
|
|
137
|
+
/** Unlike an ordinary core open, inspection must not initialize a missing profile, machine or shard. */
|
|
138
|
+
function existingInspectionProfile(project: string, cwd: string): boolean {
|
|
139
|
+
try {
|
|
140
|
+
const binding = new MemoryProfileBinding({ project, projectKey: memoryProjectKey(cwd) });
|
|
141
|
+
const profiles = [binding.profile, ...(binding.profile.sharded ? [resolveShardProfile(binding.home, { user: true })] : [])];
|
|
142
|
+
for (const profile of profiles) {
|
|
143
|
+
const machineFile = join(profile.sharded ? profile.home : profile.dir, "machine.json"), machine = lstatSync(machineFile);
|
|
144
|
+
if (!machine.isFile() || machine.isSymbolicLink()) return false;
|
|
145
|
+
const metadata = readProfileJSON(machineFile, 4096) as { id?: unknown } | undefined;
|
|
146
|
+
if (!metadata || typeof metadata.id !== "string" || !/^[a-f0-9]{16}$/.test(metadata.id)) return false;
|
|
147
|
+
const logDirectory = lstatSync(join(profile.dir, "log"));
|
|
148
|
+
const localDirectory = lstatSync(join(profile.dir, "local"));
|
|
149
|
+
if (!logDirectory.isDirectory() || logDirectory.isSymbolicLink() || !localDirectory.isDirectory() || localDirectory.isSymbolicLink()) return false;
|
|
150
|
+
}
|
|
151
|
+
binding.assertCurrent(); return true;
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Host-only doctor report: selected amem reads may maintain derived indexes/leases, never notes or candidates. */
|
|
156
|
+
export async function memoryStaleAnchorsCommand(cwd: string, opts: StaleAnchorCommandOptions = {}): Promise<MemoryStaleAnchorReport> {
|
|
157
|
+
let ownedFacts: Pick<CodeFacts, "exists" | "close"> | undefined;
|
|
158
|
+
let context: ReturnType<typeof commandStores> | undefined;
|
|
159
|
+
try {
|
|
160
|
+
const project = projectOf(cwd, opts.store);
|
|
161
|
+
if (!opts.store && !existingInspectionProfile(project, cwd)) return unavailableMemoryStaleAnchors();
|
|
162
|
+
context = commandStores(opts.store, { project, projectKey: memoryProjectKey(cwd), deferPromotions: true }, cwd);
|
|
163
|
+
const config = () => loadConfig(cwd);
|
|
164
|
+
const publicMetadataText = (text: string): boolean => {
|
|
165
|
+
try {
|
|
166
|
+
const c = config(), paths = [...(c.pii?.paths ?? []), ...c.local.deny];
|
|
167
|
+
return !looksSecret(text) && !detectSignals({ title: "", detail: text, refs: {} }, currentRouting(cwd), cwd).includes("pii")
|
|
168
|
+
&& !mentionsPrivatePath(text, [], paths, cwd)
|
|
169
|
+
&& !pathCandidates(text).some(path => isDenied(path, c.local.deny) || isDenied(foldSegment(path), c.local.deny.map(foldSegment)));
|
|
170
|
+
} catch { return false; }
|
|
171
|
+
};
|
|
172
|
+
const clock = opts.now ?? performance.now.bind(performance), metadataDeadline = clock() + MEMORY_SYMBOL_START_BUDGET_MS;
|
|
173
|
+
const blockedTasks = new Map<string, "private" | "unknown">(), repositories = new TaskRepositories(cwd); let taskSourceUnavailable = false;
|
|
174
|
+
const taskPrivacy = opts.taskPrivacy ?? ((key: string): MemoryTaskPrivacy => {
|
|
175
|
+
const identity = memoryAnchorTaskIdentity(key, project); if (!identity) return "unknown";
|
|
176
|
+
const blocked = blockedTasks.get(key); if (blocked) return blocked;
|
|
177
|
+
if (taskSourceUnavailable || clock() >= metadataDeadline) return "unknown";
|
|
178
|
+
let db: Database | undefined;
|
|
179
|
+
try {
|
|
180
|
+
const file = join(opts.stateDir ?? stateDirFor(cwd), "hub.db"), stat = lstatSync(file);
|
|
181
|
+
if (!stat.isFile() || stat.isSymbolicLink()) { taskSourceUnavailable = true; return "unknown"; }
|
|
182
|
+
db = new Database(file, { readonly: true, create: false });
|
|
183
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
184
|
+
const row = db.query(`SELECT id,created,signals,title,detail,refs,plan FROM tasks WHERE id=? AND created=?
|
|
185
|
+
AND length(signals)<=65536 AND length(title)<=300 AND length(detail)<=8000 AND length(refs)<=65536 AND length(plan)<=65536`).get(identity.id, identity.created) as { id: number; created: number; signals: string; title: string; detail: string; refs: string; plan: string } | null;
|
|
186
|
+
if (!row || row.id !== identity.id || row.created !== identity.created) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
187
|
+
const signals: unknown = JSON.parse(row.signals), refs: unknown = JSON.parse(row.refs), plan: unknown = JSON.parse(row.plan);
|
|
188
|
+
if (!Array.isArray(signals) || !signals.every(signal => typeof signal === "string") || !refs || typeof refs !== "object" || Array.isArray(refs) || !plan || typeof plan !== "object" || Array.isArray(plan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
189
|
+
const raw = [row.title, row.detail];
|
|
190
|
+
JSON.stringify({ refs, plan }, (key: string, value: unknown) => { if (key) raw.push(key); if (typeof value === "string") raw.push(value); return value; });
|
|
191
|
+
if (signals.includes("pii") || signals.includes("pii_path") || !publicMetadataText(raw.join("\n"))) { blockedTasks.set(key, "private"); return "private"; }
|
|
192
|
+
const paths = [...Object.values(refs), ...Object.values(plan)].flatMap(value => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
|
193
|
+
const c = config();
|
|
194
|
+
if (paths.some(path => isDenied(path, c.local.deny) || matchesPolicyLexically(cwd, path, [...(c.pii?.paths ?? []), ...c.local.deny]))) { blockedTasks.set(key, "private"); return "private"; }
|
|
195
|
+
const scope = repositories.scope(refs as TaskRefs);
|
|
196
|
+
if (scope.key !== repositories.project.key || repositories.mismatch(refs as TaskRefs, plan as TaskPlan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
197
|
+
return "public";
|
|
198
|
+
} catch { taskSourceUnavailable = true; return "unknown"; }
|
|
199
|
+
finally { db?.close(); }
|
|
200
|
+
});
|
|
201
|
+
const head = opts.head ?? (() => {
|
|
202
|
+
try {
|
|
203
|
+
const result = hubGitSync(["-C", cwd, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", env: hubGitEnv(process.env, { repositoryIsolation: true }), timeout: 1000, maxBuffer: 4096 });
|
|
204
|
+
const value = result.stdout.trim(); return result.status === 0 && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
|
|
205
|
+
} catch { return undefined; }
|
|
206
|
+
});
|
|
207
|
+
const facts = opts.facts ?? (ownedFacts = opts.createFacts?.() ?? new CodeFacts({ timeoutMs: MEMORY_SYMBOL_START_BUDGET_MS }));
|
|
208
|
+
const binding = context.binding;
|
|
209
|
+
return await memoryStaleAnchors({ cwd, project, store: context.store, head, now: opts.now, taskPrivacy, publicMetadataText,
|
|
210
|
+
assertCurrent: () => binding.assertCurrent(),
|
|
211
|
+
context: () => ({ cwd, project, repo: project, deny: config().local.deny, allowed: publicMetadataText }),
|
|
212
|
+
exists: (repo, at, name) => facts.exists(repo, at, name),
|
|
213
|
+
finishQueries: async () => { if (ownedFacts) { await ownedFacts.close(); ownedFacts = undefined; } } });
|
|
214
|
+
} catch { return unavailableMemoryStaleAnchors(); }
|
|
215
|
+
finally {
|
|
216
|
+
try { if (ownedFacts) await ownedFacts.close(); } catch { /* Optional cleanup failure remains neutral, including a second failed close. */ }
|
|
217
|
+
try { if (context?.owned) context.store.close(); } catch { /* Inspection must still return its neutral report. */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/cli/output.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { windowKind } from "../hub/window-kind.ts";
|
|
2
3
|
import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
|
|
3
4
|
import { peerStyle } from "../ui/peer-palette.ts";
|
|
4
5
|
import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
|
|
@@ -16,12 +17,14 @@ export interface StatusOutput {
|
|
|
16
17
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
17
18
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
18
19
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
20
|
+
memoryRead?: { truncated: boolean; scanned: number; limit: number };
|
|
19
21
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
20
22
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
21
23
|
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
22
24
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
/** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
|
|
27
|
+
export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
|
|
25
28
|
export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
|
|
26
29
|
export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
|
|
27
30
|
return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
|
|
@@ -207,6 +210,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
207
210
|
const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
|
|
208
211
|
out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
|
|
209
212
|
}
|
|
213
|
+
if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
|
|
210
214
|
if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
|
|
211
215
|
if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
|
|
212
216
|
for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
|
|
@@ -249,6 +253,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
249
253
|
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
254
|
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
251
255
|
if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
|
|
256
|
+
if (p.ownerIsolationNote) out.push(...detail("owner isolation", p.ownerIsolationNote, columns, "attention"));
|
|
252
257
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
253
258
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
254
259
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
@@ -279,6 +284,33 @@ export function stageCode(next: string): string {
|
|
|
279
284
|
}
|
|
280
285
|
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.";
|
|
281
286
|
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 + "..."; })();
|
|
287
|
+
/**
|
|
288
|
+
* #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
|
|
289
|
+
* 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.
|
|
290
|
+
*/
|
|
291
|
+
export function renderBacklog(cards: any[], columns?: number, now = Date.now(), full = false): Span[][] {
|
|
292
|
+
if (!cards.length) return [[span("no backlog cards")]];
|
|
293
|
+
const sorted = [...cards].sort((a, b) => (typeof a.rank === "number" ? a.rank : Infinity) - (typeof b.rank === "number" ? b.rank : Infinity) || a.id - b.id);
|
|
294
|
+
let names = ["RANK", "ID", "PRI", "RESERVED", "AFTER", "CLASS", "AGE", "TITLE"];
|
|
295
|
+
let table: OutputCell[][] = sorted.map(card => {
|
|
296
|
+
const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
|
|
297
|
+
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),
|
|
298
|
+
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),
|
|
299
|
+
span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
|
|
300
|
+
});
|
|
301
|
+
if (!full && columns !== undefined) {
|
|
302
|
+
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)); };
|
|
303
|
+
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);
|
|
304
|
+
const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
|
|
305
|
+
for (const name of ["AGE", "CLASS", "PRI"]) if (room() < want) drop(name);
|
|
306
|
+
const titleRoom = Math.max(5, room());
|
|
307
|
+
table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
|
|
308
|
+
}
|
|
309
|
+
const owners = new Map<string, number>();
|
|
310
|
+
for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
|
|
311
|
+
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(", ")}`;
|
|
312
|
+
return [...rows(names, table, columns), [], ...lines(summary, columns)];
|
|
313
|
+
}
|
|
282
314
|
/** #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. */
|
|
283
315
|
function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
|
|
284
316
|
const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
|
|
@@ -330,9 +362,13 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
|
|
|
330
362
|
}
|
|
331
363
|
export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
|
|
332
364
|
const out: Span[][] = [];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
365
|
+
// KIND names what the number is (#285): the provider's own (native), tokens the hub counted against a ceiling (count), or a person's (manual).
|
|
366
|
+
// A window without a kind, from an older hub, is classified by `windowKind`. A fresh window that does not gate says so in STATE.
|
|
367
|
+
const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(windowKind({ id: w.id, source: w.source ?? "", kind: w.kind }), "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : w.superseded ? "info only" : "fresh", w.stale ? "attention" : undefined)]));
|
|
368
|
+
if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "KIND", "SOURCE", "MEASURED", "STATE"], windows, columns));
|
|
369
|
+
else if (!data.peers?.length) out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
|
|
370
|
+
// A peer with no number says why, in its own line, so a peer without quota is not mistaken for one that has not reported.
|
|
371
|
+
for (const note of data.peers ?? []) out.push(...detail(note.peer, note.note, columns, peerTone(note.peer)));
|
|
336
372
|
for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
|
|
337
373
|
return out;
|
|
338
374
|
}
|
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
|
+
}
|