@staix/agent-hub 0.12.22 → 0.12.23
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 +19 -0
- package/README.md +16 -8
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/local-worker.md +3 -2
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +7 -3
- package/docs/operations.md +232 -26
- package/docs/quickstart.md +9 -4
- package/docs/security.md +15 -6
- package/docs/smoke.md +72 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +25 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +95 -13
- package/src/adapters/codex-appserver.ts +43 -13
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +19 -14
- package/src/cli/identity.ts +2 -2
- package/src/cli/init.ts +1 -1
- package/src/cli/main.ts +272 -159
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +24 -7
- package/src/cli/upgrade.ts +23 -5
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +10 -11
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +338 -23
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -0
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +129 -31
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +44 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/tool-receipts.ts +2 -1
- package/src/ui/index.html +157 -10
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
1
2
|
import { buildKimiLaunch } from "../cli/launch.ts";
|
|
2
3
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
3
4
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
@@ -36,6 +37,7 @@ import { HUB } from "./envelope.ts";
|
|
|
36
37
|
import { trimToTokens } from "../memory/recall.ts";
|
|
37
38
|
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
38
39
|
import { basename, isAbsolute } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
39
41
|
import { realPath } from "./project.ts";
|
|
40
42
|
import type { BusEvent } from "./bus.ts";
|
|
41
43
|
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS } from "./hub-tools.ts";
|
|
@@ -43,14 +45,15 @@ import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, public
|
|
|
43
45
|
import { SupervisionFeed } from "./supervision.ts";
|
|
44
46
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
45
47
|
import { launcherPreview } from "../cli/preview.ts";
|
|
46
|
-
import { Tasks } from "./tasks.ts";
|
|
48
|
+
import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
|
|
47
49
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
48
50
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
49
|
-
import { currentRouting, detectSignals } from "./routing.ts";
|
|
51
|
+
import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
52
|
+
import { checkSettingValue, ignoredLine, pendingUndo, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue } from "./settings.ts";
|
|
50
53
|
import { Bus } from "./bus.ts";
|
|
51
54
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
52
55
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
53
|
-
import { startDashboard } from "./ui.ts";
|
|
56
|
+
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
54
57
|
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
55
58
|
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
56
59
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
@@ -67,8 +70,10 @@ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./li
|
|
|
67
70
|
import { changedPaths, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
68
71
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
69
72
|
|
|
70
|
-
import {
|
|
71
|
-
import {
|
|
73
|
+
import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
74
|
+
import type { TerminalOpener } from "./terminal-open.ts";
|
|
75
|
+
import { grantablePath, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
|
|
76
|
+
import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type ContextConfig } from "./context-window.ts";
|
|
72
77
|
|
|
73
78
|
export interface HubConfig {
|
|
74
79
|
watchdog_ms: number;
|
|
@@ -105,6 +110,10 @@ export interface HubConfig {
|
|
|
105
110
|
research: { enabled: boolean };
|
|
106
111
|
/** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
|
|
107
112
|
bench: { enabled: boolean };
|
|
113
|
+
/** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
|
|
114
|
+
peers: PeerStartConfig;
|
|
115
|
+
/** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
|
|
116
|
+
terminal: { open: string[] };
|
|
108
117
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
109
118
|
limits: LimitsConfig;
|
|
110
119
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
@@ -152,6 +161,8 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
152
161
|
snapshots: { enabled: false, keep: 20 },
|
|
153
162
|
research: { enabled: false },
|
|
154
163
|
bench: { enabled: false },
|
|
164
|
+
peers: {},
|
|
165
|
+
terminal: { open: [] },
|
|
155
166
|
limits: DEFAULT_LIMITS,
|
|
156
167
|
review: { adaptive: false, min_reviews: 5 },
|
|
157
168
|
recovery: { auto_resume_after_crash: false },
|
|
@@ -167,7 +178,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
167
178
|
|
|
168
179
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
169
180
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
170
|
-
const CONFIG_BLOCKS = ["memory", "roles", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes"];
|
|
181
|
+
const CONFIG_BLOCKS = ["memory", "roles", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
|
|
171
182
|
|
|
172
183
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
173
184
|
export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"] } {
|
|
@@ -207,6 +218,9 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
207
218
|
throw error;
|
|
208
219
|
}
|
|
209
220
|
permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
|
|
221
|
+
peerStartConfig(file.peers);
|
|
222
|
+
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
223
|
+
terminalTemplate(file.terminal?.open);
|
|
210
224
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
211
225
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
212
226
|
if (why) ignored.push(why);
|
|
@@ -261,6 +275,8 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
261
275
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
262
276
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
263
277
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
278
|
+
peers: peerStartConfig(file.peers),
|
|
279
|
+
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
264
280
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
265
281
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
266
282
|
recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
|
|
@@ -280,9 +296,15 @@ export function mlxLaunchProblem(config: HubConfig, args: { backend?: unknown; m
|
|
|
280
296
|
|
|
281
297
|
/** Shipped defaults remain capability-aware; operator-written MLX pins require an explicit migration. */
|
|
282
298
|
function disabledMlxPolicyProblem(config: HubConfig, cwd: string): string | undefined {
|
|
283
|
-
if (config.mlx.enabled !== false
|
|
284
|
-
const
|
|
285
|
-
|
|
299
|
+
if (config.mlx.enabled !== false) return undefined;
|
|
300
|
+
const project = existsSync(join(cwd, ".agenthub", "routing.toml"));
|
|
301
|
+
if (!project && !existsSync(join(cwd, ".agenthub", OVERLAY_FILE))) return undefined;
|
|
302
|
+
return mlxPinProblem(currentRouting(cwd), project);
|
|
303
|
+
}
|
|
304
|
+
/** A pin a person wrote, in routing.toml or the machine-local overlay (#269); the shipped template's own pins are not theirs to migrate. */
|
|
305
|
+
function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
306
|
+
const conflict = Object.entries(routing.classes).find(([name, policy]) => policy?.pi_backend === "mlx" && (project || routing.sources?.[`classes.${name}.pi_backend`]));
|
|
307
|
+
return conflict ? `${routing.sources?.[`classes.${conflict[0]}.pi_backend`] ?? "routing.toml"}: [classes.${conflict[0]}] pi_backend=mlx conflicts with mlx.enabled=false; remove the pin for hub/auto or select dgx` : undefined;
|
|
286
308
|
}
|
|
287
309
|
|
|
288
310
|
export interface DaemonOptions {
|
|
@@ -297,6 +319,8 @@ export interface DaemonOptions {
|
|
|
297
319
|
switchyardPort?: number;
|
|
298
320
|
switchyardBin?: string;
|
|
299
321
|
config?: HubConfig;
|
|
322
|
+
/** #269: the terminal providers for a TUI start the hub makes itself, built from `terminal.open`. Absent: none, and such a start is refused. */
|
|
323
|
+
terminal?: (template: string[]) => TerminalOpener;
|
|
300
324
|
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
301
325
|
unattended?: boolean;
|
|
302
326
|
permissionTimeoutMs?: number;
|
|
@@ -474,6 +498,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
474
498
|
parsed = JSON.parse(String(data));
|
|
475
499
|
onMessage(sock, parsed);
|
|
476
500
|
} catch (e) {
|
|
501
|
+
if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
|
|
502
|
+
const busy = new RecoveryDatabaseBusy();
|
|
503
|
+
try { log(`control ${sanitize(String(parsed.t))} database contention: ${databaseBusySite(e).join("\n")}`); } catch { /* the reply still settles its rid */ }
|
|
504
|
+
sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: busy.message, code: busy.code, retryable: busy.retryable }));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
477
507
|
log("control operation failed; inspect delivery storage and recovery status");
|
|
478
508
|
if (sock.data.authed && Number.isSafeInteger(parsed?.rid)) sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: "operation failed; inspect delivery storage and recovery status before retrying" }));
|
|
479
509
|
}
|
|
@@ -1020,9 +1050,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1020
1050
|
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
1021
1051
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
1022
1052
|
resumed: (record) => {
|
|
1053
|
+
// Coalesce unread notices without forgetting the public moves they carried.
|
|
1054
|
+
const moves = new Map<string, typeof record.moved[number]>();
|
|
1055
|
+
const earlier: string[] = [];
|
|
1056
|
+
const superseded: string[] = [];
|
|
1057
|
+
for (const env of bus.snapshot(false).queues[record.peer] ?? []) {
|
|
1058
|
+
if (env.from !== HUB || env.kind !== "budget" || env.refs?.budgetResume !== record.peer) continue;
|
|
1059
|
+
for (const move of env.refs.budgetResumeMoves ?? []) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
|
|
1060
|
+
earlier.push(...(env.refs.budgetResumeEarlier ?? []));
|
|
1061
|
+
// A notice written by an older hub has no structured moves: retain its whole public text.
|
|
1062
|
+
if (!env.refs.budgetResumeMoves) earlier.push(env.body);
|
|
1063
|
+
superseded.push(env.id);
|
|
1064
|
+
}
|
|
1065
|
+
for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
|
|
1066
|
+
const unreadMoves = [...moves.values()];
|
|
1023
1067
|
if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
|
|
1024
|
-
const moved =
|
|
1025
|
-
bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important" }));
|
|
1068
|
+
const moved = unreadMoves.length ? `While you were paused these moved: ${unreadMoves.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : earlier.length ? "Moves from earlier pauses are preserved in the unread notices below." : "Nothing was moved while you were paused.";
|
|
1069
|
+
bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved}${earlier.length ? ` Earlier unread resume notices: ${earlier.join("\n")}` : ""} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important", refs: { budgetResume: record.peer, budgetResumeMoves: unreadMoves, ...(earlier.length ? { budgetResumeEarlier: earlier } : {}) } }));
|
|
1070
|
+
// Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
|
|
1071
|
+
for (const id of superseded) bus.withdraw(id);
|
|
1026
1072
|
},
|
|
1027
1073
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1028
1074
|
});
|
|
@@ -1124,7 +1170,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1124
1170
|
for (const peer of bus.knownPeers()) {
|
|
1125
1171
|
contexts.retry(peer, contextSession(peer));
|
|
1126
1172
|
const reading = contexts.view(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer)));
|
|
1127
|
-
|
|
1173
|
+
// An unchanged reading is not repeated (#309): the stamp is what the printed line says apart from its
|
|
1174
|
+
// time — the percentage as printed (or unknown), the freshness and the source — never measuredAt or tokens.
|
|
1175
|
+
const stamp = `${reading.used === null ? "unknown" : Math.round(reading.used * 100)}|${reading.freshness}|${reading.source}`;
|
|
1128
1176
|
if (contextTailSeen.get(peer) !== stamp) {
|
|
1129
1177
|
contextTailSeen.set(peer, stamp);
|
|
1130
1178
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
|
|
@@ -1739,6 +1787,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1739
1787
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
1740
1788
|
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
1741
1789
|
} else report(step.how);
|
|
1790
|
+
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
1791
|
+
if (step.tui && startMode("pi") === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
|
|
1742
1792
|
const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
1743
1793
|
const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
|
|
1744
1794
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
@@ -1835,7 +1885,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1835
1885
|
if (task?.owner === e.peer && ["proposed", "in_progress", "changes_requested"].includes(task.state)) {
|
|
1836
1886
|
const reason = tasks.isPii(task) ? "private delivery retries exhausted" : sanitize(e.reason ?? "delivery retries exhausted").replace(/\s+/g, " ").slice(0, 300);
|
|
1837
1887
|
notify(`task ${tasks.publicTitle(task)}: undeliverable to ${e.peer}: ${reason}; escalating`);
|
|
1838
|
-
void tasks.escalate(HUB, task.id, `Undeliverable to ${e.peer}: ${reason}`, "delivery_failed").catch(() =>
|
|
1888
|
+
void tasks.escalate(HUB, task.id, `Undeliverable to ${e.peer}: ${reason}`, "delivery_failed").catch((err) => {
|
|
1889
|
+
// A saved move whose delivery failed was already noticed where the error was thrown; anything else is
|
|
1890
|
+
// an escalation that did not happen (#297).
|
|
1891
|
+
if (!(err instanceof AssignmentUndeliveredError)) notify(`task ${tasks.publicTitle(task)} could not be escalated; inspect with ahub task show ${task.id}`);
|
|
1892
|
+
});
|
|
1839
1893
|
}
|
|
1840
1894
|
}
|
|
1841
1895
|
} else {
|
|
@@ -1937,6 +1991,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1937
1991
|
let piAutoRestartAt = -Infinity;
|
|
1938
1992
|
let piAutoRestartPending = false;
|
|
1939
1993
|
const replacingPi = new Set<PiPeer>();
|
|
1994
|
+
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
1995
|
+
const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
|
|
1996
|
+
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
1997
|
+
(error) => log(`Pi auto-start failed: ${(error as Error).message}`));
|
|
1940
1998
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
1941
1999
|
replacingPi.add(pi);
|
|
1942
2000
|
try { await pi.stop(); } finally { replacingPi.delete(pi); }
|
|
@@ -1964,6 +2022,56 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1964
2022
|
return running;
|
|
1965
2023
|
}
|
|
1966
2024
|
|
|
2025
|
+
// #269: start modes. A start the hub makes itself (the dashboard's Start, `peer_start` from a console child, the
|
|
2026
|
+
// conductor, pi.auto_start) follows the peer's start mode. A TUI needs a terminal: a provider opens one running the
|
|
2027
|
+
// fixed `ahub <peer>` command, and with no provider the start is refused with that command. It is never downgraded.
|
|
2028
|
+
const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
|
|
2029
|
+
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
2030
|
+
/** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
|
|
2031
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer);
|
|
2032
|
+
const startWords = (peer: string): string[] => peer === "pi" ? ["pi", "--mode", "tui"] : [peer];
|
|
2033
|
+
const startable = (peer: string) => peer !== "pi" || config.pi.enabled;
|
|
2034
|
+
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
2035
|
+
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
2036
|
+
const opening = new Map<string, number>();
|
|
2037
|
+
/** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
|
|
2038
|
+
// ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
|
|
2039
|
+
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
2040
|
+
const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
|
|
2041
|
+
return { peer, mode, attached: !!owner && owner.state !== "offline", command: `ahub ${startWords(peer).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
|
|
2042
|
+
});
|
|
2043
|
+
async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
|
|
2044
|
+
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
2045
|
+
if (!isStartablePeer(peer) || !startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
2046
|
+
if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
|
|
2047
|
+
const mode = startMode(peer);
|
|
2048
|
+
const owner = bus.peers.get(peer);
|
|
2049
|
+
if (mode === "headless") {
|
|
2050
|
+
// A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
|
|
2051
|
+
// ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
|
|
2052
|
+
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
2053
|
+
// issued to tell the two apart.
|
|
2054
|
+
if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
|
|
2055
|
+
return startPeer(peer, peer === "pi" ? { mode: "headless" } : {});
|
|
2056
|
+
}
|
|
2057
|
+
if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
|
|
2058
|
+
const command = `ahub ${startWords(peer).join(" ")}`;
|
|
2059
|
+
const since = Date.now() - (opening.get(peer) ?? 0);
|
|
2060
|
+
if (since < PEER_START_RETRY_MS) return { ok: false, error: `a terminal for ${peer} was opened ${Math.round(since / 1000)}s ago; wait for its TUI, or run ${command} yourself`, command };
|
|
2061
|
+
// Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
|
|
2062
|
+
opening.set(peer, Date.now());
|
|
2063
|
+
// Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
|
|
2064
|
+
const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...startWords(peer)]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
|
|
2065
|
+
if (!opened.ok) {
|
|
2066
|
+
opening.delete(peer); // nothing was opened: the next request may try again at once
|
|
2067
|
+
if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
|
|
2068
|
+
const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
|
|
2069
|
+
return { ok: false, error: `${peer} starts in its TUI and the hub could not open a terminal for it (${opened.why}). Run ${command} in a terminal${headless}`, command };
|
|
2070
|
+
}
|
|
2071
|
+
try { notify(`${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
|
|
2072
|
+
return { ok: true, mode, opened: opened.via, command };
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1967
2075
|
async function stopPeer(peer: unknown): Promise<Record<string, unknown>> {
|
|
1968
2076
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
1969
2077
|
if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
|
|
@@ -2089,6 +2197,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2089
2197
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
2090
2198
|
log,
|
|
2091
2199
|
onTokens: onKimiTokens,
|
|
2200
|
+
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
2201
|
+
onQuotaRefusal: (window) => {
|
|
2202
|
+
budget.report("kimi", [window], true);
|
|
2203
|
+
return !!budget.record("kimi") && bus.isPaused("kimi");
|
|
2204
|
+
},
|
|
2205
|
+
// Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
|
|
2206
|
+
// context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
|
|
2207
|
+
// adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
|
|
2208
|
+
// shape reports a null reading: unknown, not the previous value (#285).
|
|
2209
|
+
onUsageDiagnostic: (observation, sessionId) => {
|
|
2210
|
+
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
2211
|
+
const current = contextSession("kimi");
|
|
2212
|
+
if (!current) return;
|
|
2213
|
+
contexts.report("kimi",
|
|
2214
|
+
observation.contextUsed !== undefined && observation.contextCapacity !== undefined
|
|
2215
|
+
? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
|
|
2216
|
+
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
|
|
2217
|
+
current);
|
|
2218
|
+
},
|
|
2092
2219
|
onTurnFailure: () => { supervisionTurns.delete("kimi"); },
|
|
2093
2220
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
2094
2221
|
preamble: roleContract("kimi", config.roles),
|
|
@@ -2321,7 +2448,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2321
2448
|
const task = board.get(Number(id));
|
|
2322
2449
|
if (!task || task.owner !== "pi" || tasks.isPii(task) || !["proposed", "in_progress", "changes_requested"].includes(task.state)) continue;
|
|
2323
2450
|
try { await tasks.escalate(HUB, task.id, "Pi inference failed after accepting the turn. Prior tool effects may be partial or uncertain. Inspect the working tree and Pi session before continuing; do not blindly repeat writes or commands.", "inference_failed"); }
|
|
2324
|
-
|
|
2451
|
+
// A saved move whose delivery failed was already noticed where the error was thrown (#297).
|
|
2452
|
+
catch (err) { if (!(err instanceof AssignmentUndeliveredError)) notify(`Pi task #${task.id} could not be escalated; inspect it with ahub task show`); }
|
|
2325
2453
|
}
|
|
2326
2454
|
if (!ids.length) notify("Pi inference failed; inspect its session before retrying any effects");
|
|
2327
2455
|
},
|
|
@@ -2494,10 +2622,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2494
2622
|
launcherPreview(peer, peer === "pi" ? ["--mode", "tui"] : [], opts.cwd, opts.stateDir, false);
|
|
2495
2623
|
return `ahub ${peer}${peer === "pi" ? " --mode tui" : ""}`;
|
|
2496
2624
|
},
|
|
2625
|
+
// #269: the conductor's start is the hub's own: the peer's start mode decides, never the caller.
|
|
2626
|
+
startMode: peer => startMode(peer),
|
|
2497
2627
|
start: async peer => {
|
|
2498
|
-
const
|
|
2499
|
-
if (existing instanceof PiPeer && (existing.recoveryMetadata().launch as { mode?: string })?.mode === "tui") throw new Error("Pi owns a TUI; ask the person to change its mode");
|
|
2500
|
-
const result = await startPeer(peer, { mode: "headless" });
|
|
2628
|
+
const result = await hubStart(peer, "the conductor");
|
|
2501
2629
|
if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
|
|
2502
2630
|
return result;
|
|
2503
2631
|
},
|
|
@@ -2549,7 +2677,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2549
2677
|
arm: rs[0]!.header.arm, runs: rs.length, measures: benchReport(rs).overall })) } };
|
|
2550
2678
|
} catch { return {}; } // an unreadable store never breaks the dashboard
|
|
2551
2679
|
}
|
|
2552
|
-
function uiSnapshot(after: number) {
|
|
2680
|
+
function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
|
|
2553
2681
|
const quota = budget.status();
|
|
2554
2682
|
const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
|
|
2555
2683
|
return {
|
|
@@ -2573,12 +2701,157 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2573
2701
|
}),
|
|
2574
2702
|
events: uiEvents.filter((e) => e.seq > after),
|
|
2575
2703
|
cursor: uiSequence,
|
|
2704
|
+
...settingsSnapshot(session),
|
|
2705
|
+
starts: startPlans(),
|
|
2576
2706
|
...benchView(),
|
|
2577
2707
|
};
|
|
2578
2708
|
}
|
|
2579
2709
|
|
|
2580
|
-
|
|
2710
|
+
// #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
|
|
2711
|
+
let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
|
|
2712
|
+
/** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
|
|
2713
|
+
const storedConfig = (): HubConfig | undefined => {
|
|
2714
|
+
const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
|
|
2715
|
+
if (storedConfigCache?.stamp !== stamp) {
|
|
2716
|
+
let loaded: HubConfig | undefined;
|
|
2717
|
+
try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
|
|
2718
|
+
storedConfigCache = { stamp, config: loaded };
|
|
2719
|
+
}
|
|
2720
|
+
return storedConfigCache.config;
|
|
2721
|
+
};
|
|
2722
|
+
/** Start modes are the one stored setting a running hub takes up: after a settings write, as the files now read. */
|
|
2723
|
+
const applyStartModes = () => { const stored = storedConfig(); if (stored) config.peers = stored.peers; };
|
|
2724
|
+
const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
2725
|
+
const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
|
|
2726
|
+
settingScalar(def.path[0] === "stay_switch" ? routing.stay_switch : (routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
|
|
2727
|
+
/** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
|
|
2728
|
+
// ponytail: reads the two config files and the undo record and parses the routing files on each dashboard poll; cache by mtime if a page ever feels it.
|
|
2729
|
+
function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
|
|
2730
|
+
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
2731
|
+
catch { return {}; }
|
|
2732
|
+
}
|
|
2733
|
+
function settingsView(): { rows: SettingRow[]; undo?: string } {
|
|
2734
|
+
const stored = storedConfig();
|
|
2735
|
+
// What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
|
|
2736
|
+
// merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
|
|
2737
|
+
let routingProblem: string | undefined;
|
|
2738
|
+
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
2739
|
+
const rows = settingRows({
|
|
2740
|
+
cwd: opts.cwd, routing: currentRouting(opts.cwd, log), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
2741
|
+
running: (path) => valueAt(config, path),
|
|
2742
|
+
next: stored ? (path) => valueAt(stored, path) : undefined,
|
|
2743
|
+
mode: (peer) => {
|
|
2744
|
+
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
2745
|
+
const note = pendingPermissionDefaults.has(peer) ? "a never-ask default waits for y in ahub console" : attached ? undefined : "not attached: only ask can be set";
|
|
2746
|
+
return { value: attached ? permissionDisplay(peer, owner) : permissionMode(peer), ...(note ? { note } : {}) };
|
|
2747
|
+
},
|
|
2748
|
+
});
|
|
2749
|
+
const undo = pendingUndo(opts.stateDir);
|
|
2750
|
+
return { rows, ...(undo ? { undo } : {}) };
|
|
2751
|
+
}
|
|
2752
|
+
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
2753
|
+
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
|
|
2754
|
+
event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}) });
|
|
2755
|
+
try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
|
|
2756
|
+
};
|
|
2757
|
+
const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
2758
|
+
/** A checked definition and value, or the refusal. Every settings write and preview starts here. */
|
|
2759
|
+
function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
|
|
2760
|
+
const def = settingDef(key);
|
|
2761
|
+
if (typeof def === "string") return { error: def };
|
|
2762
|
+
const routing = currentRouting(opts.cwd, log);
|
|
2763
|
+
try { return { def, value: checkSettingValue(def, raw, [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
|
|
2764
|
+
catch (error) { return { error: (error as Error).message }; }
|
|
2765
|
+
}
|
|
2766
|
+
async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown): Promise<Record<string, unknown>> {
|
|
2767
|
+
const input = settingInput(key, raw);
|
|
2768
|
+
if ("error" in input) return { ok: false, error: input.error };
|
|
2769
|
+
const { def, value } = input;
|
|
2770
|
+
const refusal = settingRefusal(def, value, authority);
|
|
2771
|
+
if (refusal) return { ok: false, error: refusal };
|
|
2772
|
+
const source = settingSource(authority);
|
|
2773
|
+
const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
|
|
2774
|
+
// The console asks for y before never-ask; here the person types the peer it is for.
|
|
2775
|
+
if (value === "never-ask" && confirm !== peer) return { ok: false, error: `never-ask for ${peer} needs its peer id as the confirmation; nothing was changed` };
|
|
2776
|
+
if (def.store === "runtime") {
|
|
2777
|
+
// The console's own request (#242): the same refusals, start reconciliation and events.
|
|
2778
|
+
if (requestedPeerStops.has(peer!)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
2779
|
+
const from = permissionMode(peer!);
|
|
2780
|
+
const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
|
|
2781
|
+
if (result.ok !== true) return result;
|
|
2782
|
+
recordSetting(def.key, from, permissionMode(peer!), source);
|
|
2783
|
+
return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
|
|
2784
|
+
}
|
|
2785
|
+
try {
|
|
2786
|
+
if (def.store === "routing") {
|
|
2787
|
+
const from = routingValue(currentRouting(opts.cwd, log), def);
|
|
2788
|
+
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, routingCheck);
|
|
2789
|
+
if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
2790
|
+
recordSetting(def.key, from, routingValue(next, def), source);
|
|
2791
|
+
return { ok: true, text: `${def.key}: ${settingText(routingValue(next, def))} (in force now)` };
|
|
2792
|
+
}
|
|
2793
|
+
// Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
|
|
2794
|
+
if (peer && value === null && storedLocally(opts.cwd, def) && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" && confirm !== peer) return { ok: false, error: `removing the machine-local value leaves never-ask from config.json for ${peer}: it needs the peer id as the confirmation; nothing was changed` };
|
|
2795
|
+
const from = settingScalar(valueAt(storedConfig(), def.path));
|
|
2796
|
+
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => loadConfig(scratch))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
2797
|
+
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
2798
|
+
applyStartModes();
|
|
2799
|
+
recordSetting(def.key, from, to, source);
|
|
2800
|
+
// Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
|
|
2801
|
+
const dropped = ignoredLine(storedConfig()?.ignored, def);
|
|
2802
|
+
return { ok: true, text: dropped ? `${def.key}: written, but the hub does not read it (${dropped})` : `${def.key}: ${settingText(to)} (${def.applies === "peer start" ? "applies at the peer's next start" : "applies at the next hub start"})` };
|
|
2803
|
+
} catch (error) {
|
|
2804
|
+
return { ok: false, error: (error as Error).message };
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
/** A pin the hub could not start with: refused before a write or an undo puts it on disk. */
|
|
2808
|
+
// As at hub start: with no project routing.toml only a pin the overlay sets counts, never the shipped template's own.
|
|
2809
|
+
const routingCheck = (routing: Routing) => { const problem = config.mlx.enabled === false ? mlxPinProblem(routing, existsSync(join(opts.cwd, ".agenthub", "routing.toml"))) : undefined; if (problem) throw new Error(problem); };
|
|
2810
|
+
function settingsUndo(authority: SettingAuthority): Record<string, unknown> {
|
|
2811
|
+
// The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
|
|
2812
|
+
if (authority === "ordinary") return { ok: false, error: "undo can put back a value that widens what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings undo in a terminal" };
|
|
2813
|
+
const def = settingDef(pendingUndo(opts.stateDir));
|
|
2814
|
+
if (typeof def === "string") return { ok: false, error: "nothing to undo" };
|
|
2815
|
+
const read = (): SettingValue => def.store === "routing" ? routingValue(currentRouting(opts.cwd, log), def) : settingScalar(valueAt(storedConfig(), def.path));
|
|
2816
|
+
// never-ask is set with its confirmation, never put back by an undo that carries none.
|
|
2817
|
+
if (def.group === "Permissions" && restoredValue(opts.cwd, opts.stateDir, def) === "never-ask") return { ok: false, error: `undo would put never-ask back for ${def.path.at(-1)} without its confirmation: set ${def.key} again (never-ask, or inherit where the shared file holds it) with the confirmation instead; nothing was undone` };
|
|
2818
|
+
try {
|
|
2819
|
+
const from = read();
|
|
2820
|
+
undoSetting(opts.cwd, opts.stateDir, (scratch) => loadConfig(scratch), routingCheck);
|
|
2821
|
+
applyStartModes();
|
|
2822
|
+
recordSetting(def.key, from, read(), settingSource(authority), true);
|
|
2823
|
+
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
return { ok: false, error: (error as Error).message };
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
/** What a change would do, before it is saved: what the mode grants, or `route explain` for the open tasks before and after. */
|
|
2829
|
+
function settingsPreview(key: unknown, raw: unknown): Record<string, unknown> {
|
|
2830
|
+
const input = settingInput(key, raw);
|
|
2831
|
+
if ("error" in input) return { ok: false, error: input.error };
|
|
2832
|
+
const { def, value } = input;
|
|
2833
|
+
if (def.group === "Permissions") {
|
|
2834
|
+
const peer = def.path.at(-1)!;
|
|
2835
|
+
return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
|
|
2836
|
+
}
|
|
2837
|
+
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : "read at the next hub start"}`] };
|
|
2838
|
+
try {
|
|
2839
|
+
const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
|
|
2840
|
+
const open = board.list().filter((task) => task.state !== "approved");
|
|
2841
|
+
// A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
|
|
2842
|
+
const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
|
|
2843
|
+
return { ok: true, lines: [`${def.key}: ${settingText(routingValue(currentRouting(opts.cwd, log), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
|
|
2844
|
+
tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
|
|
2845
|
+
...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
|
|
2846
|
+
} catch (error) {
|
|
2847
|
+
return { ok: false, error: (error as Error).message };
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
2581
2852
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
2853
|
+
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
2854
|
+
const authority: SettingAuthority = session?.settings ? "settings" : "ordinary";
|
|
2582
2855
|
const text = (key: string, max: number) => typeof a[key] === "string" && (a[key] as string).length <= max;
|
|
2583
2856
|
const peer = () => text("peer", 32) && PEER_ID.test(a.peer as string);
|
|
2584
2857
|
const taskId = () => typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id > 0;
|
|
@@ -2610,6 +2883,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2610
2883
|
}
|
|
2611
2884
|
case "assign":
|
|
2612
2885
|
return taskId() && peer() ? { ok: true, text: await taskOp(USER, "task_assign", { id: a.id, peer: a.peer }) } : bad;
|
|
2886
|
+
case "start_peer": {
|
|
2887
|
+
// #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
|
|
2888
|
+
if (!peer()) return bad;
|
|
2889
|
+
const result = await hubStart(a.peer, "the dashboard");
|
|
2890
|
+
if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).` };
|
|
2891
|
+
// A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
|
|
2892
|
+
if (typeof result.command === "string") return { ok: false, error: String(result.error) };
|
|
2893
|
+
log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
|
|
2894
|
+
return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
|
|
2895
|
+
}
|
|
2896
|
+
case "setting":
|
|
2897
|
+
return settingsSet(a.key, a.value, authority, a.confirm);
|
|
2898
|
+
case "setting_undo":
|
|
2899
|
+
return settingsUndo(authority);
|
|
2900
|
+
case "setting_preview":
|
|
2901
|
+
return settingsPreview(a.key, a.value);
|
|
2613
2902
|
default:
|
|
2614
2903
|
return bad;
|
|
2615
2904
|
}
|
|
@@ -2967,7 +3256,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2967
3256
|
}
|
|
2968
3257
|
case "recovery":
|
|
2969
3258
|
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
2970
|
-
void recoveryOp(msg).then(reply, (error) =>
|
|
3259
|
+
void recoveryOp(msg).then(reply, (error) => {
|
|
3260
|
+
if (isDatabaseBusy(error)) {
|
|
3261
|
+
try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
|
|
3262
|
+
const busy = new RecoveryDatabaseBusy();
|
|
3263
|
+
reply({ ...recoveryError(busy.message), code: busy.code, retryable: busy.retryable });
|
|
3264
|
+
} else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
|
|
3265
|
+
});
|
|
2971
3266
|
return;
|
|
2972
3267
|
case "send": {
|
|
2973
3268
|
if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
|
|
@@ -3160,7 +3455,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3160
3455
|
try {
|
|
3161
3456
|
dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
|
|
3162
3457
|
writeStatus();
|
|
3163
|
-
|
|
3458
|
+
// #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
|
|
3459
|
+
if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
|
|
3460
|
+
return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true), ...(msg.settings === true ? { settings: true } : {}) });
|
|
3164
3461
|
} catch {
|
|
3165
3462
|
return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
|
|
3166
3463
|
}
|
|
@@ -3177,6 +3474,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3177
3474
|
if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
|
|
3178
3475
|
void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
|
|
3179
3476
|
return;
|
|
3477
|
+
case "settings_get":
|
|
3478
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
3479
|
+
return void reply({ ok: true, ...settingsView() });
|
|
3480
|
+
case "settings_preview":
|
|
3481
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
3482
|
+
return void reply(settingsPreview(msg.key, msg.value));
|
|
3483
|
+
case "settings_set":
|
|
3484
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
3485
|
+
void settingsSet(msg.key, msg.value, "terminal", msg.confirm).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
|
|
3486
|
+
return;
|
|
3487
|
+
case "settings_undo":
|
|
3488
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
3489
|
+
return void reply(settingsUndo("terminal"));
|
|
3490
|
+
case "peer_start":
|
|
3491
|
+
// #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
|
|
3492
|
+
if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
|
|
3493
|
+
void hubStart(msg.peer, "the console").then(reply, (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
|
|
3494
|
+
return;
|
|
3180
3495
|
case "peer_stop":
|
|
3181
3496
|
if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
|
|
3182
3497
|
void stopPeer(msg.peer).then(reply).catch(() => reply({ ok: false, error: "peer stop failed; inspect ahub status and logs before retrying" }));
|
|
@@ -3348,12 +3663,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3348
3663
|
void recoverAfterCrash(crashed).catch((error) => {
|
|
3349
3664
|
log(`crash recovery failed: ${(error as Error).message}`);
|
|
3350
3665
|
// pi.auto_start still holds: a running Pi answers `already`, a starting one refuses the second start.
|
|
3351
|
-
if (piAutoStart)
|
|
3666
|
+
if (piAutoStart) autoStartPi();
|
|
3352
3667
|
});
|
|
3353
3668
|
}
|
|
3354
3669
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
3355
3670
|
const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
|
|
3356
|
-
if (piAutoStart && !recoveryActive() && !piRecovered)
|
|
3671
|
+
if (piAutoStart && !recoveryActive() && !piRecovered) autoStartPi();
|
|
3357
3672
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
3358
3673
|
} finally {
|
|
3359
3674
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
@@ -4,6 +4,7 @@ import { Database } from "bun:sqlite";
|
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
5
|
import type { Envelope } from "./envelope.ts";
|
|
6
6
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
7
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
8
|
|
|
8
9
|
export type JournalDeliveryState =
|
|
9
10
|
| "queued"
|
|
@@ -239,10 +240,10 @@ export class DeliveryJournal {
|
|
|
239
240
|
this.operationId = options.operationId;
|
|
240
241
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
241
242
|
this.db = new Database(options.file, { create: true });
|
|
243
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
242
244
|
this.harden();
|
|
243
245
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
244
246
|
this.db.run("PRAGMA synchronous = FULL");
|
|
245
|
-
this.db.run("PRAGMA busy_timeout = 3000");
|
|
246
247
|
this.createSchema();
|
|
247
248
|
const meta = this.readMeta();
|
|
248
249
|
if (!meta)
|
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
package/src/hub/events.ts
CHANGED
|
@@ -12,6 +12,8 @@ export const EVENTS_SCHEMA = 1;
|
|
|
12
12
|
*/
|
|
13
13
|
export type HubEvent =
|
|
14
14
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
15
|
+
/** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
|
|
16
|
+
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
|
|
15
17
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
16
18
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
17
19
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|