@staix/agent-hub 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -6
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +17 -3
- package/docs/agent-notes/budget.md +4 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +14 -9
- package/docs/agent-notes/tasks.md +9 -4
- package/docs/agent-notes/tests.md +3 -3
- package/docs/dogfood.md +9 -4
- package/docs/events.md +21 -7
- package/docs/operations.md +90 -18
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +17 -0
- package/docs/security.md +37 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +29 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +992 -312
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +14 -2
- 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/registry-types.ts +7 -3
- 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 +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +4 -3
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +47 -9
- package/src/cli/output.ts +28 -0
- 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-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +26 -2
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +368 -59
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +19 -3
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +1 -1
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/tasks.ts +202 -15
- package/src/hub/token-window.ts +33 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +115 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { normalizeUsage } from "../omniroute/usage.ts";
|
|
3
|
+
import { permissionPayload } from "./permission-payload.ts";
|
|
4
|
+
import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
|
|
1
5
|
import { InboxPolling } from "./inbox-polling.ts";
|
|
2
6
|
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
7
|
+
import { actionGate, claudeActionClasses } from "./action-enforce.ts";
|
|
3
8
|
import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
|
|
4
9
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
|
|
5
10
|
import type { DocEvidence } from "./sdlc-release.ts";
|
|
@@ -58,7 +63,11 @@ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrust
|
|
|
58
63
|
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
59
64
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
60
65
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
61
|
-
import {
|
|
66
|
+
import { createConfigEditor } from "./config-edit-host.ts";
|
|
67
|
+
import { readEditable } from "./config-edit.ts";
|
|
68
|
+
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
69
|
+
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
70
|
+
import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
|
|
62
71
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
63
72
|
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
64
73
|
import type { ServerWebSocket } from "bun";
|
|
@@ -93,7 +102,7 @@ import { fileURLToPath } from "node:url";
|
|
|
93
102
|
import { realPath, hubHome } from "./project.ts";
|
|
94
103
|
import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
|
|
95
104
|
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
96
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
105
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
97
106
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
98
107
|
import { SupervisionFeed } from "./supervision.ts";
|
|
99
108
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -500,6 +509,8 @@ export interface DaemonOptions {
|
|
|
500
509
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
501
510
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
502
511
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
512
|
+
/** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
|
|
513
|
+
kimiHome?: string;
|
|
503
514
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
504
515
|
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
505
516
|
projectId?: string;
|
|
@@ -544,6 +555,7 @@ interface Client {
|
|
|
544
555
|
role?: "peer" | "console" | "tools";
|
|
545
556
|
peer?: PeerId;
|
|
546
557
|
launchId?: string;
|
|
558
|
+
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
547
559
|
tail?: () => void;
|
|
548
560
|
watch?: () => void;
|
|
549
561
|
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
@@ -603,7 +615,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
603
615
|
const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
|
|
604
616
|
...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
|
|
605
617
|
const executionRoots = new Map<string, string>();
|
|
606
|
-
const
|
|
618
|
+
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
619
|
+
const implementerTranscripts = new Map<string, string>();
|
|
620
|
+
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
621
|
+
const verifiedTerminalRootsThisTurn = new Set<object>();
|
|
607
622
|
const reviewerUnavailable = new Map<string, string>();
|
|
608
623
|
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
609
624
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
@@ -679,6 +694,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
679
694
|
let configRevision = 0;
|
|
680
695
|
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
681
696
|
let pendingRestart: { key: string; reason: string }[] = [];
|
|
697
|
+
// #689: structural config edits. Assigned with the settings section; status and the settings view read it before then, so it is held here.
|
|
698
|
+
let configEditor: ReturnType<typeof createConfigEditor> | undefined;
|
|
699
|
+
const structuralScopes = new Set<string>();
|
|
700
|
+
let sdlcFileReads = 0;
|
|
701
|
+
let sdlcFileCache: { stamp: string; value: { mode: string; requiredChecks: readonly string[] } | string } | undefined;
|
|
702
|
+
/** What waits for a restart: the reload's own keys, then a changed sdlc.toml (read once at start). */
|
|
703
|
+
const allPendingRestart = (): { key: string; reason: string }[] => [...pendingRestart, ...(configEditor?.pendingRestart() ?? [])];
|
|
682
704
|
let retainedConfig: { key: string; reason: string }[] = [];
|
|
683
705
|
function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
|
|
684
706
|
const rows = new Map<string, string>();
|
|
@@ -840,6 +862,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
840
862
|
sock.data.watch?.();
|
|
841
863
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
842
864
|
if (isWsPeer(peer)) {
|
|
865
|
+
if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
|
|
843
866
|
peer.detach(sock);
|
|
844
867
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
845
868
|
}
|
|
@@ -1405,12 +1428,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1405
1428
|
reviewOnly: reviewOnlyPeer,
|
|
1406
1429
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1407
1430
|
reviewAuthority: runningReviewAuthority,
|
|
1431
|
+
requestAuthority: by => {
|
|
1432
|
+
const scope = childToolAuthority.getStore();
|
|
1433
|
+
return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
|
|
1434
|
+
},
|
|
1408
1435
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1409
1436
|
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1410
1437
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1411
1438
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1412
1439
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
1413
|
-
const bound =
|
|
1440
|
+
const bound = currentExecutionOwner(id);
|
|
1414
1441
|
return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
|
|
1415
1442
|
})),
|
|
1416
1443
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
@@ -1419,6 +1446,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1419
1446
|
profile: profileName(config.profile),
|
|
1420
1447
|
profileData: resolvedProfile,
|
|
1421
1448
|
piiPaths: config.pii?.paths,
|
|
1449
|
+
deny: config.local.deny,
|
|
1450
|
+
// #509a: deliverables are hashed under the owner's verified execution root (a declared worktree instance's worktree), `undefined` when unproven.
|
|
1451
|
+
executionRoot: peer => ownerWriteRoot(peer),
|
|
1422
1452
|
catalog,
|
|
1423
1453
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1424
1454
|
projectedReview: task => reviewProjection(task),
|
|
@@ -1554,7 +1584,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1554
1584
|
const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
|
|
1555
1585
|
const worktreeFacts = new Map<string, Facts>();
|
|
1556
1586
|
const worktreeProjections = new Map<string, TextProjections>();
|
|
1557
|
-
const executionRoot = (peer: string) =>
|
|
1587
|
+
const executionRoot = (peer: string) => {
|
|
1588
|
+
if (executionOwners.get(peer)?.socket && !currentExecutionOwner(peer)) return opts.cwd;
|
|
1589
|
+
return executionRoots.get(peer) ?? opts.cwd;
|
|
1590
|
+
};
|
|
1558
1591
|
const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
|
|
1559
1592
|
const projectionsFor = (peer: string): TextProjections => {
|
|
1560
1593
|
const root = executionRoot(peer);
|
|
@@ -1856,7 +1889,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1856
1889
|
}
|
|
1857
1890
|
for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
|
|
1858
1891
|
const unreadMoves = [...moves.values()];
|
|
1859
|
-
if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume)
|
|
1892
|
+
if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiUsage.reset(); // a new window: the old counts would pause it again at once
|
|
1860
1893
|
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.";
|
|
1861
1894
|
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 } : {}) } }));
|
|
1862
1895
|
// Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
|
|
@@ -1868,8 +1901,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1868
1901
|
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1869
1902
|
// 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
|
|
1870
1903
|
// read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
|
|
1871
|
-
|
|
1872
|
-
|
|
1904
|
+
const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
|
|
1905
|
+
budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
|
|
1906
|
+
// #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
|
|
1907
|
+
const kimiUsage = new KimiUsage({
|
|
1908
|
+
home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
|
|
1909
|
+
now: () => Date.now(),
|
|
1910
|
+
ceiling: () => config.budget.kimi_tokens_5h,
|
|
1911
|
+
session: peer => contextSession(peer),
|
|
1912
|
+
record: (peer, row, counted) => {
|
|
1913
|
+
addTokens(peer, counted);
|
|
1914
|
+
event({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
|
|
1915
|
+
cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
|
|
1916
|
+
...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
|
|
1917
|
+
},
|
|
1918
|
+
report: (peer, window) => budget.report(peer, [{ id: "tokens", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
|
|
1919
|
+
forget: peer => budget.forget(peer, "tokens"),
|
|
1920
|
+
notice: line => notify(line),
|
|
1921
|
+
});
|
|
1922
|
+
kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
|
|
1923
|
+
/** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a usage_update. */
|
|
1924
|
+
const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
|
|
1925
|
+
const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
1873
1926
|
startupCleanup.push(() => budget.close());
|
|
1874
1927
|
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1875
1928
|
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
@@ -1968,13 +2021,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1968
2021
|
function privateSnapshotWindow(): boolean {
|
|
1969
2022
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1970
2023
|
}
|
|
2024
|
+
function revokeTerminalExecution(peer: string): void {
|
|
2025
|
+
const bound = executionOwners.get(peer);
|
|
2026
|
+
if (bound) verifiedTerminalRootsThisTurn.delete(bound);
|
|
2027
|
+
executionOwners.delete(peer); executionRoots.delete(peer);
|
|
2028
|
+
worktreeFacts.delete(peer); worktreeProjections.delete(peer);
|
|
2029
|
+
}
|
|
2030
|
+
function currentExecutionOwner(peer: string) {
|
|
2031
|
+
const bound = executionOwners.get(peer);
|
|
2032
|
+
if (bound?.socket) {
|
|
2033
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2034
|
+
if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2035
|
+
revokeTerminalExecution(peer);
|
|
2036
|
+
return undefined;
|
|
2037
|
+
}
|
|
2038
|
+
// A new event-loop turn revalidates Git inventory and the attached branch before
|
|
2039
|
+
// an admission/effect. Reuse only within synchronous code, never across an await.
|
|
2040
|
+
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2041
|
+
try {
|
|
2042
|
+
if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2043
|
+
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2044
|
+
verifiedTerminalRootsThisTurn.add(bound);
|
|
2045
|
+
queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
2046
|
+
}
|
|
2047
|
+
// The hello claim is not attached until its asynchronous preface finishes.
|
|
2048
|
+
if (!bound.owner.owns(bound.socket)) return undefined;
|
|
2049
|
+
} else if (bound && definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
2050
|
+
try {
|
|
2051
|
+
if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
|
|
2052
|
+
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2053
|
+
if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2054
|
+
verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
2055
|
+
}
|
|
2056
|
+
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2057
|
+
}
|
|
2058
|
+
return bound;
|
|
2059
|
+
}
|
|
1971
2060
|
function ownerWriteRoot(peer: string): string | undefined {
|
|
1972
2061
|
if (declarations[peer]) {
|
|
1973
|
-
const bound =
|
|
2062
|
+
const bound = currentExecutionOwner(peer);
|
|
1974
2063
|
if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
|
|
1975
2064
|
}
|
|
1976
2065
|
return executionRoot(peer);
|
|
1977
2066
|
}
|
|
2067
|
+
function implementerChatTask(peer: string): number | undefined {
|
|
2068
|
+
const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
|
|
2069
|
+
return owned.length === 1 ? owned[0]!.id : undefined;
|
|
2070
|
+
}
|
|
2071
|
+
function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
|
|
2072
|
+
const bound = c.toolsOwner, peer = c.peer;
|
|
2073
|
+
if (!bound || !peer || c.role !== "tools" || stopping || recoveryActive() || !membershipAllowed(peer) || contextPii(peer) || definitionOf(peer)?.start.daemonPort !== "claude-implementer" || bus.peers.get(peer) !== bound.owner) return undefined;
|
|
2074
|
+
const launch = bound.owner.ownedTools?.(bound.launchId);
|
|
2075
|
+
if (!launch || launch.signal.aborted || launch.generation !== bound.generation || bound.cwd === opts.cwd) return undefined;
|
|
2076
|
+
const root = verifyRoot ? ownerWriteRoot(peer) : executionOwners.get(peer)?.owner === bound.owner ? executionRoots.get(peer) : undefined;
|
|
2077
|
+
if (root !== bound.cwd) return undefined;
|
|
2078
|
+
if (launch.task !== undefined) {
|
|
2079
|
+
const task = board.get(launch.task);
|
|
2080
|
+
if (!task || task.owner !== peer || task.state === "cancelled" || tasks.isPii(task)) return undefined;
|
|
2081
|
+
}
|
|
2082
|
+
return launch;
|
|
2083
|
+
}
|
|
2084
|
+
function captureImplementerTools(c: Client): () => boolean {
|
|
2085
|
+
const binding = c.toolsOwner, launch = currentImplementerTools(c);
|
|
2086
|
+
return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
|
|
2087
|
+
}
|
|
1978
2088
|
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
1979
2089
|
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
1980
2090
|
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
@@ -2033,28 +2143,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2033
2143
|
const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
|
|
2034
2144
|
return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
|
|
2035
2145
|
}
|
|
2036
|
-
|
|
2037
|
-
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2146
|
+
/** Tokens a peer used, as increments. */
|
|
2038
2147
|
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
2039
|
-
const addTokens = (peer: PeerId, n: number): number => {
|
|
2148
|
+
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
2040
2149
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
2041
2150
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
2042
2151
|
if (n > 0) {
|
|
2043
|
-
event({ type: "tokens", peer, n, ...taskAttribution(peer) });
|
|
2152
|
+
event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
|
|
2044
2153
|
const turn = turns.get(peer);
|
|
2045
2154
|
if (turn) turn.tokens += n;
|
|
2046
2155
|
}
|
|
2047
2156
|
return n;
|
|
2048
2157
|
};
|
|
2049
|
-
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
2050
|
-
const n = addTokens(peer, delta(peer, total, session));
|
|
2051
|
-
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
2052
|
-
const now = Date.now();
|
|
2053
|
-
kimiTokens.push({ at: now, n });
|
|
2054
|
-
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
2055
|
-
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
2056
|
-
budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
2057
|
-
};
|
|
2058
2158
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
2059
2159
|
let claudeUsageSeen = 0;
|
|
2060
2160
|
let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
|
|
@@ -2085,7 +2185,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2085
2185
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
2086
2186
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
2087
2187
|
for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
|
|
2088
|
-
for (const row of bus.queueList()) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
|
|
2188
|
+
for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
|
|
2089
2189
|
feedBudgetPauses();
|
|
2090
2190
|
supervision.checkRound();
|
|
2091
2191
|
try {
|
|
@@ -2435,7 +2535,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2435
2535
|
case "hub_task_decline":
|
|
2436
2536
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
2437
2537
|
case "hub_task_done": {
|
|
2438
|
-
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
2538
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
|
|
2439
2539
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
2440
2540
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
2441
2541
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -2449,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2449
2549
|
}
|
|
2450
2550
|
case "hub_sdlc_record":
|
|
2451
2551
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2452
|
-
return line(tasks.
|
|
2552
|
+
return line(await tasks.recordSdlc(by, a.id, a.receipt));
|
|
2453
2553
|
case "hub_review":
|
|
2454
2554
|
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2455
2555
|
case "hub_remember":
|
|
@@ -2492,7 +2592,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2492
2592
|
const task = board.get(Number(a.id));
|
|
2493
2593
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2494
2594
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2495
|
-
|
|
2595
|
+
const peerDeliverables = await tasks.deliverableStatus(task);
|
|
2596
|
+
return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), ...(peerDeliverables ? { deliverables: peerDeliverables } : {}), flowRevision: task.history.length });
|
|
2496
2597
|
}
|
|
2497
2598
|
if (op === "route_explain" && by !== USER) {
|
|
2498
2599
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2528,7 +2629,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2528
2629
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2529
2630
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2530
2631
|
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2531
|
-
|
|
2632
|
+
const consoleDeliverables = await tasks.deliverableStatus(task);
|
|
2633
|
+
return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2532
2634
|
}
|
|
2533
2635
|
case "task_assign":
|
|
2534
2636
|
case "task_assign_checked": {
|
|
@@ -2613,6 +2715,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2613
2715
|
}
|
|
2614
2716
|
};
|
|
2615
2717
|
const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
|
|
2718
|
+
// #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
|
|
2719
|
+
const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
|
|
2720
|
+
task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
|
|
2721
|
+
nameable: text => tasks.nameable(text) }, peer);
|
|
2722
|
+
// A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
|
|
2723
|
+
// Claude: the launcher installed the PreToolUse hook (an `--unattended` launch skips every permission check, hooks included). ACP: a hub-owned
|
|
2724
|
+
// adapter with the gate, and only while its native mode is `ask`: Kimi's `yolo` and `auto` modes send no request for a call they allow.
|
|
2725
|
+
const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
|
|
2726
|
+
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
2727
|
+
const launch = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
|
|
2728
|
+
// The launch record outlives the process, so it proves nothing alone: the attached session must be the generation whose hook last spoke to the hub.
|
|
2729
|
+
if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
|
|
2730
|
+
if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
|
|
2731
|
+
if (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
|
|
2732
|
+
return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
|
|
2733
|
+
return { enforced: true };
|
|
2734
|
+
}
|
|
2735
|
+
const owner = bus.peers.get(peer);
|
|
2736
|
+
if (isAcpPeer(owner) && owner.actionGated) {
|
|
2737
|
+
const mode = owner.permissionModeState;
|
|
2738
|
+
return mode === "ask" ? { enforced: true } : { enforced: false, reason: mode === "unknown" || mode === "unmanaged" ? "its native approval mode is not managed by the hub, so a request may never be sent" : `its native mode ${mode} sends no request for a call it allows` };
|
|
2739
|
+
}
|
|
2740
|
+
return { enforced: false };
|
|
2741
|
+
};
|
|
2616
2742
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2617
2743
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2618
2744
|
const unattendedPeers = new Set<string>();
|
|
@@ -2632,8 +2758,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2632
2758
|
owner: row => {
|
|
2633
2759
|
const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
|
|
2634
2760
|
if (!proof) return undefined;
|
|
2761
|
+
const terminalRootCurrent = () => !declarations[row.peer] || definitionOf(row.peer)?.transport !== "claude-channel" || !config.roles[row.peer]?.includes("implementer") || (ownerWriteRoot(row.peer) === row.executionCwd && row.executionCwd !== opts.cwd);
|
|
2762
|
+
if (!terminalRootCurrent()) return undefined;
|
|
2635
2763
|
return { ...proof, isCurrent: () => {
|
|
2636
|
-
if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
|
|
2764
|
+
if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner || !terminalRootCurrent()) return false;
|
|
2637
2765
|
const current = owner?.terminalOwner?.(row.launchId);
|
|
2638
2766
|
return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
|
|
2639
2767
|
} };
|
|
@@ -2706,8 +2834,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2706
2834
|
if (value.instanceId !== instanceId) return {};
|
|
2707
2835
|
let currentLauncher = false;
|
|
2708
2836
|
try {
|
|
2709
|
-
const launch =
|
|
2710
|
-
if (launch
|
|
2837
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2838
|
+
if (launch) {
|
|
2711
2839
|
currentLauncher = true;
|
|
2712
2840
|
if (value.launchId !== launch.launchId) return {};
|
|
2713
2841
|
}
|
|
@@ -2806,7 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2806
2934
|
// filled one stays in the row and never matches.
|
|
2807
2935
|
const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
|
|
2808
2936
|
const integrity = (older = 0) => {
|
|
2809
|
-
const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
2937
|
+
const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
2810
2938
|
const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
|
|
2811
2939
|
const row: Record<string, unknown> = { ...t };
|
|
2812
2940
|
for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
|
|
@@ -2885,7 +3013,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2885
3013
|
instanceId,
|
|
2886
3014
|
version: VERSION,
|
|
2887
3015
|
pluginBundle: SERVER_JS,
|
|
2888
|
-
pendingRestart,
|
|
3016
|
+
pendingRestart: allPendingRestart(),
|
|
2889
3017
|
retainedConfig,
|
|
2890
3018
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2891
3019
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
@@ -2904,6 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2904
3032
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2905
3033
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2906
3034
|
activeInstances: extraIds,
|
|
3035
|
+
ownerIsolation: Object.fromEntries(extraIds.map(id => [id, !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd])),
|
|
3036
|
+
ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => definitionOf(id)?.transport === "claude-channel" && !ownerWriteRoot(id)).map(id => [id, "terminal execution root not verified; owner admission refused"])),
|
|
2907
3037
|
stopping,
|
|
2908
3038
|
pid: process.pid,
|
|
2909
3039
|
cwd: opts.cwd,
|
|
@@ -2911,11 +3041,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2911
3041
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2912
3042
|
codexProxyPort: opts.codexProxyPort,
|
|
2913
3043
|
peers: Object.fromEntries(
|
|
2914
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
3044
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2915
3045
|
),
|
|
2916
3046
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2917
3047
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2918
|
-
actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id,
|
|
3048
|
+
actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
|
|
2919
3049
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2920
3050
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2921
3051
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -2969,7 +3099,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2969
3099
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
2970
3100
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
2971
3101
|
function heldRows(peer: PeerId) {
|
|
2972
|
-
return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
|
|
3102
|
+
return bus.queueList(peer, true).filter(row => row.state === "needs_review").map(row => ({
|
|
2973
3103
|
id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
|
|
2974
3104
|
kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
|
|
2975
3105
|
context: deliveryTaskContext(row.originals, board, peer),
|
|
@@ -2985,7 +3115,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2985
3115
|
if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
2986
3116
|
settlingSuperseded = true;
|
|
2987
3117
|
try {
|
|
2988
|
-
for (const row of bus.queueList()) {
|
|
3118
|
+
for (const row of bus.queueList(undefined, true)) {
|
|
2989
3119
|
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
2990
3120
|
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
2991
3121
|
if (!reason) continue;
|
|
@@ -3286,7 +3416,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3286
3416
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3287
3417
|
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
3288
3418
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
3419
|
+
if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
|
|
3289
3420
|
} else if (!busy && open) {
|
|
3421
|
+
collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
|
|
3290
3422
|
turns.delete(e.peer);
|
|
3291
3423
|
let afterTurn: (() => void) | undefined;
|
|
3292
3424
|
let files: number | undefined;
|
|
@@ -3374,6 +3506,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3374
3506
|
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
3375
3507
|
}
|
|
3376
3508
|
}
|
|
3509
|
+
if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
|
|
3510
|
+
// #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
|
|
3511
|
+
// session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
|
|
3512
|
+
if (e.state === "offline") actionBudgets.revoke(e.peer);
|
|
3377
3513
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3378
3514
|
else offlineSince.delete(e.peer);
|
|
3379
3515
|
// A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
|
|
@@ -3460,7 +3596,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3460
3596
|
catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
|
|
3461
3597
|
};
|
|
3462
3598
|
if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
|
|
3463
|
-
|
|
3599
|
+
// Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
|
|
3600
|
+
if (opts.unattended && !req.classed) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
|
|
3464
3601
|
const id = randomUUID().slice(0, 8);
|
|
3465
3602
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
3466
3603
|
// could repaint the terminal line, so everything but newline and tab is made visible.
|
|
@@ -3676,7 +3813,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3676
3813
|
const owner = bus.peers.get(peer);
|
|
3677
3814
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3678
3815
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3679
|
-
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3816
|
+
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3680
3817
|
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3681
3818
|
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3682
3819
|
if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
|
|
@@ -3852,9 +3989,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3852
3989
|
}
|
|
3853
3990
|
if (adapter?.start.acpCommand) {
|
|
3854
3991
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
3992
|
+
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
3855
3993
|
const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
|
|
3856
3994
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3857
3995
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3996
|
+
actionGate: acpActionGate(peer),
|
|
3858
3997
|
cmd, kind: adapter.kind,
|
|
3859
3998
|
permissionMode: permissionMode(peer),
|
|
3860
3999
|
...(args.model ? { launchModel: args.model } : {}),
|
|
@@ -3868,7 +4007,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3868
4007
|
// session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
|
|
3869
4008
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3870
4009
|
log,
|
|
3871
|
-
onTokens: (total, session) => onKimiTokens(total, session, peer),
|
|
3872
4010
|
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
3873
4011
|
onQuotaRefusal: (window) => {
|
|
3874
4012
|
budget.report(peer, [window], true);
|
|
@@ -3879,6 +4017,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3879
4017
|
// adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
|
|
3880
4018
|
// shape reports a null reading: unknown, not the previous value (#285).
|
|
3881
4019
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
4020
|
+
if (observation.source === "usage_update") collectKimiUsage(peer); // phase 2: the update is the cue to read the wire log, never a token total
|
|
3882
4021
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3883
4022
|
const current = contextSession(peer);
|
|
3884
4023
|
if (!current) return;
|
|
@@ -3904,9 +4043,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3904
4043
|
userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
|
|
3905
4044
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3906
4045
|
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
4046
|
+
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
3907
4047
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3908
4048
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3909
4049
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
4050
|
+
actionGate: acpActionGate(peer),
|
|
3910
4051
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3911
4052
|
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3912
4053
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
@@ -4256,6 +4397,51 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4256
4397
|
await local.start();
|
|
4257
4398
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
4258
4399
|
}
|
|
4400
|
+
if (adapter?.start.daemonPort === "claude-implementer") {
|
|
4401
|
+
if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
|
|
4402
|
+
const currentRoot = () => {
|
|
4403
|
+
const roles = config.roles[peer] ?? [];
|
|
4404
|
+
return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveInstanceExecutionCwd(opts.cwd, peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
|
|
4405
|
+
};
|
|
4406
|
+
try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
|
|
4407
|
+
catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
|
|
4408
|
+
if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
|
|
4409
|
+
if (existing) { mute(existing); await existing.stop(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
|
|
4410
|
+
const selected = permissionMode(peer);
|
|
4411
|
+
if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
|
|
4412
|
+
const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
|
|
4413
|
+
cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
|
|
4414
|
+
factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
|
|
4415
|
+
admit: env => {
|
|
4416
|
+
try {
|
|
4417
|
+
if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
|
|
4418
|
+
if (env.refs?.task === undefined) return true;
|
|
4419
|
+
const task = board.get(Number(env.refs.task));
|
|
4420
|
+
return !!task && task.owner === peer && !tasks.isPii(task) && ["proposed", "in_progress", "changes_requested"].includes(task.state);
|
|
4421
|
+
} catch { return false; }
|
|
4422
|
+
},
|
|
4423
|
+
chatTask: () => implementerChatTask(peer),
|
|
4424
|
+
transcriptPersisted: (task, session) => {
|
|
4425
|
+
const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
|
|
4426
|
+
return path ? pathEvidence(path, lstatSync) : "unknown";
|
|
4427
|
+
},
|
|
4428
|
+
onUsage: (usage, task) => {
|
|
4429
|
+
if (bus.peers.get(peer) !== implementer) return;
|
|
4430
|
+
const normalized = normalizeUsage(usage), total = claudeReportedTokens(normalized);
|
|
4431
|
+
if (total !== undefined) addTokens(peer, total, task);
|
|
4432
|
+
event({ type: "usage", peer, source: "claude_result", id: `${runId}:${peer}:${++codexUsageSeq}`, ...normalized,
|
|
4433
|
+
...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
|
|
4434
|
+
}, notify,
|
|
4435
|
+
}) });
|
|
4436
|
+
await implementer.start();
|
|
4437
|
+
try {
|
|
4438
|
+
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4439
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4440
|
+
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4441
|
+
executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
|
|
4442
|
+
return { ok: true };
|
|
4443
|
+
} catch (error) { await implementer.stop().catch(() => {}); throw error; }
|
|
4444
|
+
}
|
|
4259
4445
|
if (adapter?.reviewer) {
|
|
4260
4446
|
const backend = adapter.reviewer.backend;
|
|
4261
4447
|
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
@@ -4545,7 +4731,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4545
4731
|
instanceId,
|
|
4546
4732
|
attention: attentionStatus.attentionInput,
|
|
4547
4733
|
pet,
|
|
4548
|
-
status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4734
|
+
status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4549
4735
|
boardCapabilities: { backlog: true },
|
|
4550
4736
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
4551
4737
|
releases: tasks.releaseList(USER),
|
|
@@ -4674,7 +4860,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4674
4860
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4675
4861
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4676
4862
|
}
|
|
4677
|
-
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
4863
|
+
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key), structural: !!scope && structuralScopes.has(scope) };
|
|
4678
4864
|
const decision = authorizeConfigLeaf(input);
|
|
4679
4865
|
if (!decision.allowed) {
|
|
4680
4866
|
const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
|
|
@@ -4854,7 +5040,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4854
5040
|
/** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
|
|
4855
5041
|
// 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.
|
|
4856
5042
|
function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
|
|
4857
|
-
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
5043
|
+
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
|
|
4858
5044
|
catch { return {}; }
|
|
4859
5045
|
}
|
|
4860
5046
|
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
|
|
@@ -4876,7 +5062,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4876
5062
|
});
|
|
4877
5063
|
const undo = pendingUndo(opts.stateDir);
|
|
4878
5064
|
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
4879
|
-
return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
5065
|
+
return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
4880
5066
|
}
|
|
4881
5067
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
4882
5068
|
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
@@ -5114,6 +5300,46 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5114
5300
|
return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
|
|
5115
5301
|
}
|
|
5116
5302
|
|
|
5303
|
+
// #689: structural config edits (src/hub/config-edit-host.ts). The editor decides authority, the typed confirmation and the audit event;
|
|
5304
|
+
// the daemon supplies the loaders' verdict, the hold and the one scoped reload path. A structural key is allowed through reload authority
|
|
5305
|
+
// only while its own scope is registered here, for the duration of that one reload.
|
|
5306
|
+
configEditor = createConfigEditor({
|
|
5307
|
+
cwd: opts.cwd, stateDir: opts.stateDir,
|
|
5308
|
+
check: (file, text) => {
|
|
5309
|
+
if (file !== "config.local.json") return;
|
|
5310
|
+
const texts = configSourceTexts(opts.cwd);
|
|
5311
|
+
texts["config.local.json"] = text;
|
|
5312
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
5313
|
+
const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
5314
|
+
// Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
|
|
5315
|
+
checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
|
|
5316
|
+
},
|
|
5317
|
+
held: () => stopping || recoveryActive() ? "recovery or shutdown" : undefined,
|
|
5318
|
+
reload: async (authority, key) => {
|
|
5319
|
+
structuralScopes.add(key);
|
|
5320
|
+
try { return await reloadConfig(authority, key); } finally { structuralScopes.delete(key); }
|
|
5321
|
+
},
|
|
5322
|
+
event: entry => event(entry),
|
|
5323
|
+
notify: text => notify(text),
|
|
5324
|
+
changed: () => writeStatus(),
|
|
5325
|
+
pending: () => pendingRestart,
|
|
5326
|
+
effective: path => valueAt(config, path),
|
|
5327
|
+
runningSdlc: () => ({ mode: lifecycleConfig.mode, requiredChecks: lifecycleConfig.requiredChecks }),
|
|
5328
|
+
// Status and the settings view poll this: read sdlc.toml again only when its stamp changed. ponytail: the stamp is mtime, ctime, inode and size, so an in-place rewrite of the same size inside one filesystem timestamp tick is missed until the next change; it feeds display only (a status line), never an authority. A watcher is the upgrade if a poll ever shows.
|
|
5329
|
+
fileSdlc: () => {
|
|
5330
|
+
const stamp = (() => { try { const info = statSync(join(opts.cwd, ".agenthub", "sdlc.toml")); return `${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.size}`; } catch { return "-"; } })();
|
|
5331
|
+
if (sdlcFileCache?.stamp !== stamp) {
|
|
5332
|
+
let value: { mode: string; requiredChecks: readonly string[] } | string;
|
|
5333
|
+
sdlcFileReads++;
|
|
5334
|
+
try { const loaded = loadSdlc(opts.cwd, VERSION); value = { mode: loaded.mode, requiredChecks: loaded.requiredChecks }; } catch (error) { value = sanitize((error as Error).message).slice(0, 300); }
|
|
5335
|
+
sdlcFileCache = { stamp, value };
|
|
5336
|
+
}
|
|
5337
|
+
return sdlcFileCache.value;
|
|
5338
|
+
},
|
|
5339
|
+
declared: () => Object.fromEntries(Object.entries(declarations).map(([id, value]) => [id, { ...value }])),
|
|
5340
|
+
localConfig: () => { try { const text = readEditable(opts.cwd, "config.local.json"); const doc = text === null ? {} : JSON.parse(text); return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {}; } catch { return {}; } },
|
|
5341
|
+
});
|
|
5342
|
+
|
|
5117
5343
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
5118
5344
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
5119
5345
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -5199,6 +5425,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5199
5425
|
return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
|
|
5200
5426
|
case "setting_reload":
|
|
5201
5427
|
return reloadConfig(authority);
|
|
5428
|
+
case "config_edit_preview":
|
|
5429
|
+
return configEditor!.preview(a.edit, authority);
|
|
5430
|
+
case "config_edit_apply":
|
|
5431
|
+
// A dashboard session is never given the typed confirmation of an executable key: that is the console's, and refused here by the editor.
|
|
5432
|
+
return configEditor!.apply(a.edit, authority, undefined, a.reason);
|
|
5202
5433
|
default:
|
|
5203
5434
|
return bad;
|
|
5204
5435
|
}
|
|
@@ -5245,6 +5476,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5245
5476
|
if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
|
|
5246
5477
|
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
5247
5478
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
5479
|
+
if (extraIds.some(id => adapterCatalog.definitionOf(declarations[id]!.kind)?.transport === "claude-channel" && declarations[id]!.roles?.includes("implementer")) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("role_stages"))) return "target lacks role_stages; declared Claude implementer authority cannot be restored by that daemon";
|
|
5248
5480
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
5249
5481
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
5250
5482
|
return undefined;
|
|
@@ -5366,11 +5598,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5366
5598
|
}
|
|
5367
5599
|
|
|
5368
5600
|
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
5372
|
-
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
5373
|
-
} catch { return undefined; }
|
|
5601
|
+
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
5602
|
+
return row?.permissionHook ? row : undefined;
|
|
5374
5603
|
}
|
|
5375
5604
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5376
5605
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
@@ -5480,7 +5709,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5480
5709
|
|
|
5481
5710
|
function onMessage(sock: Sock, msg: any): void {
|
|
5482
5711
|
const c = sock.data;
|
|
5483
|
-
const reply = (body: Record<string, unknown>) => {
|
|
5712
|
+
const reply = (body: Record<string, unknown>) => {
|
|
5713
|
+
if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
|
|
5714
|
+
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
5715
|
+
};
|
|
5484
5716
|
if (!c.authed) {
|
|
5485
5717
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
5486
5718
|
if (msg.v !== PROTOCOL) {
|
|
@@ -5501,6 +5733,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5501
5733
|
c.peer = String(msg.peer ?? "");
|
|
5502
5734
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5503
5735
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5736
|
+
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
5737
|
+
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
5738
|
+
if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
|
|
5739
|
+
c.launchId = msg.launchId;
|
|
5740
|
+
c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
|
|
5741
|
+
if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
|
|
5742
|
+
}
|
|
5504
5743
|
// #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
|
|
5505
5744
|
// peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
|
|
5506
5745
|
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
@@ -5517,8 +5756,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5517
5756
|
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
5518
5757
|
let currentLaunch: unknown;
|
|
5519
5758
|
try {
|
|
5520
|
-
const launch =
|
|
5521
|
-
if (launch
|
|
5759
|
+
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5760
|
+
if (launch) currentLaunch = launch.launchId;
|
|
5522
5761
|
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
5523
5762
|
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
5524
5763
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
@@ -5534,6 +5773,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5534
5773
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
5535
5774
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
5536
5775
|
ws.claim(sock, msg.channel === false);
|
|
5776
|
+
if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
5777
|
+
revokeTerminalExecution(c.peer);
|
|
5778
|
+
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5779
|
+
try {
|
|
5780
|
+
const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
|
|
5781
|
+
if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
|
|
5782
|
+
executionRoots.set(c.peer, root);
|
|
5783
|
+
executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
|
|
5784
|
+
}
|
|
5785
|
+
} catch { /* Unknown or changed worktree evidence never grants owner isolation. */ }
|
|
5786
|
+
}
|
|
5537
5787
|
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
|
|
5538
5788
|
if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
|
|
5539
5789
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
@@ -5548,7 +5798,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5548
5798
|
}
|
|
5549
5799
|
if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
|
|
5550
5800
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
5801
|
+
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
5551
5802
|
switch (msg.t) {
|
|
5803
|
+
case "permission_request": {
|
|
5804
|
+
const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
|
|
5805
|
+
if (!peer || !launch || c.role !== "tools" || typeof msg.tool !== "string" || !msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)) return void reply({ ok: false, behavior: "deny", message: "permission unavailable" });
|
|
5806
|
+
const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
|
|
5807
|
+
const pending = new AbortController(), abortPending = () => pending.abort();
|
|
5808
|
+
launch.signal.addEventListener("abort", abortPending, { once: true });
|
|
5809
|
+
// Heartbeats keep an approval alive; they grant nothing. Admission and the final
|
|
5810
|
+
// answer still revalidate native root evidence, without synchronous Git on every tick.
|
|
5811
|
+
const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
|
|
5812
|
+
c.toolsOwner?.owner.keepApprovalAlive?.();
|
|
5813
|
+
void requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal).then(result => {
|
|
5814
|
+
const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
|
|
5815
|
+
reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
|
|
5816
|
+
}, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
|
|
5817
|
+
return;
|
|
5818
|
+
}
|
|
5552
5819
|
case "permission_default": {
|
|
5553
5820
|
if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
|
|
5554
5821
|
if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
|
|
@@ -5624,6 +5891,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5624
5891
|
return void reply({ ok: true });
|
|
5625
5892
|
}
|
|
5626
5893
|
case "inbox": {
|
|
5894
|
+
if (c.toolsOwner) {
|
|
5895
|
+
const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
|
|
5896
|
+
try {
|
|
5897
|
+
// Inspect the still-pending batch before pull commits/removes it: private work never reaches this native child.
|
|
5898
|
+
const pending = bus.pendingEnvelopes(peer);
|
|
5899
|
+
if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
|
|
5900
|
+
if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
|
|
5901
|
+
const envs = bus.pull(peer);
|
|
5902
|
+
if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
|
|
5903
|
+
return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
5904
|
+
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
5905
|
+
}
|
|
5627
5906
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5628
5907
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5629
5908
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
@@ -5792,6 +6071,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5792
6071
|
};
|
|
5793
6072
|
try {
|
|
5794
6073
|
const peer = c.peer;
|
|
6074
|
+
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
6075
|
+
const launch = currentImplementerTools(c), root = ownerWriteRoot(peer);
|
|
6076
|
+
if (!launch || !root || root === opts.cwd || sessionId !== launch.sessionId || msg.nativeLaunchId !== launch.launchId || msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
6077
|
+
if (typeof msg.transcriptPath === "string" && isAbsolute(msg.transcriptPath) && basename(msg.transcriptPath) === `${launch.sessionId}.jsonl`) {
|
|
6078
|
+
try {
|
|
6079
|
+
const projects = realPath(join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects")), transcript = realPath(msg.transcriptPath);
|
|
6080
|
+
if (transcript.startsWith(`${projects}/`) && statSync(transcript).isFile() && launch.task !== undefined) implementerTranscripts.set(`${peer}:${launch.task}:${launch.sessionId}`, transcript);
|
|
6081
|
+
} catch { /* Exact path is not yet present or is unreadable: unknown preserves session identity. */ }
|
|
6082
|
+
}
|
|
6083
|
+
const mode = permissionMode(peer);
|
|
6084
|
+
if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
|
|
6085
|
+
const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
|
|
6086
|
+
const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
|
|
6087
|
+
return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
|
|
6088
|
+
}
|
|
5795
6089
|
let nativeClaude = false;
|
|
5796
6090
|
if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
|
|
5797
6091
|
let expectedLaunch: string | undefined;
|
|
@@ -5800,8 +6094,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5800
6094
|
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
|
|
5801
6095
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
5802
6096
|
try {
|
|
5803
|
-
const launch =
|
|
5804
|
-
if (launch
|
|
6097
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6098
|
+
if (launch) expectedLaunch = launch.launchId;
|
|
5805
6099
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
5806
6100
|
const current = claudeSession(peer);
|
|
5807
6101
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
@@ -5818,10 +6112,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5818
6112
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
5819
6113
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5820
6114
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5821
|
-
const
|
|
6115
|
+
const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
|
|
6116
|
+
if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
|
|
6117
|
+
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
|
|
5822
6118
|
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5823
|
-
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5824
|
-
|
|
6119
|
+
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
6120
|
+
// Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
|
|
6121
|
+
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
|
|
6122
|
+
const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {};
|
|
5825
6123
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5826
6124
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5827
6125
|
const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
|
|
@@ -6069,7 +6367,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6069
6367
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
6070
6368
|
case "settings_get":
|
|
6071
6369
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6072
|
-
return void reply({ ok: true, ...settingsView(), roster: rosterView() });
|
|
6370
|
+
return void reply({ ok: true, ...settingsView(), roster: rosterView(), configEdit: (() => { const view = configEditor!.view(); return { revisions: view.revisions, known: view.known }; })() });
|
|
6073
6371
|
case "settings_reload":
|
|
6074
6372
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
6075
6373
|
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
@@ -6083,6 +6381,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6083
6381
|
case "settings_undo":
|
|
6084
6382
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6085
6383
|
return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
|
|
6384
|
+
case "config_edit_get":
|
|
6385
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6386
|
+
return void reply({ ok: true, ...configEditor!.view() });
|
|
6387
|
+
case "config_edit_preview":
|
|
6388
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6389
|
+
return void reply(configEditor!.preview(msg.edit, "terminal"));
|
|
6390
|
+
case "config_edit_apply":
|
|
6391
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6392
|
+
void configEditor!.apply(msg.edit, "terminal", msg.confirm, msg.reason).then(reply, () => reply({ ok: false, error: "the config edit could not be completed; inspect hub.log" }));
|
|
6393
|
+
return;
|
|
6086
6394
|
case "kimi_launch_end": {
|
|
6087
6395
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
6088
6396
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -6132,8 +6440,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6132
6440
|
});
|
|
6133
6441
|
return;
|
|
6134
6442
|
case "task":
|
|
6443
|
+
if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
|
|
6135
6444
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
6136
|
-
taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
|
|
6445
|
+
(c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
|
|
6137
6446
|
(text) => reply({ t: "task", ok: true, text }),
|
|
6138
6447
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
6139
6448
|
);
|
|
@@ -6393,7 +6702,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6393
6702
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
6394
6703
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
6395
6704
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6396
|
-
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6705
|
+
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6397
6706
|
} finally {
|
|
6398
6707
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6399
6708
|
}
|