@staix/agent-hub 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
package/src/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";
|
|
@@ -75,7 +84,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
75
84
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
76
85
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
77
86
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
78
|
-
import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
87
|
+
import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
79
88
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
80
89
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
81
90
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
@@ -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";
|
|
@@ -106,7 +115,8 @@ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUn
|
|
|
106
115
|
import { Bus } from "./bus.ts";
|
|
107
116
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
108
117
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
109
|
-
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
118
|
+
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
119
|
+
import { codeGroup } from "./task-code-group.ts";
|
|
110
120
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
111
121
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
112
122
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
@@ -290,9 +300,10 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
290
300
|
if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
|
|
291
301
|
for (const [peer, assigned] of Object.entries(file.roles)) {
|
|
292
302
|
conductorPeer({ [peer]: assigned });
|
|
293
|
-
const
|
|
303
|
+
const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
|
|
294
304
|
const declared = assigned as string[];
|
|
295
|
-
|
|
305
|
+
// #525 R1: planner needs a kind that can plan (projection.plannerEligible); such an entry is dropped with the ceiling notice, never thrown.
|
|
306
|
+
roles[peer] = (ceiling ? declared.filter(role => ceiling.includes(role)) : declared).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
|
|
296
307
|
if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
|
|
297
308
|
}
|
|
298
309
|
}
|
|
@@ -498,6 +509,8 @@ export interface DaemonOptions {
|
|
|
498
509
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
499
510
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
500
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;
|
|
501
514
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
502
515
|
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
503
516
|
projectId?: string;
|
|
@@ -521,6 +534,10 @@ export interface DaemonOptions {
|
|
|
521
534
|
orphanWatchMs?: number;
|
|
522
535
|
/** Deterministic task-sweep time; the production default is Date.now. */
|
|
523
536
|
taskSweepNow?: () => number;
|
|
537
|
+
/** Test seam: the period of the SDLC band evaluation, default five minutes. */
|
|
538
|
+
bandsEveryMs?: number;
|
|
539
|
+
/** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
|
|
540
|
+
bandsHeld?: () => boolean;
|
|
524
541
|
/** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
|
|
525
542
|
researchGraceMs?: number;
|
|
526
543
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
@@ -538,6 +555,7 @@ interface Client {
|
|
|
538
555
|
role?: "peer" | "console" | "tools";
|
|
539
556
|
peer?: PeerId;
|
|
540
557
|
launchId?: string;
|
|
558
|
+
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
541
559
|
tail?: () => void;
|
|
542
560
|
watch?: () => void;
|
|
543
561
|
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
@@ -597,7 +615,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
597
615
|
const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
|
|
598
616
|
...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
|
|
599
617
|
const executionRoots = new Map<string, string>();
|
|
600
|
-
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>();
|
|
601
622
|
const reviewerUnavailable = new Map<string, string>();
|
|
602
623
|
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
603
624
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
@@ -673,6 +694,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
673
694
|
let configRevision = 0;
|
|
674
695
|
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
675
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() ?? [])];
|
|
676
704
|
let retainedConfig: { key: string; reason: string }[] = [];
|
|
677
705
|
function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
|
|
678
706
|
const rows = new Map<string, string>();
|
|
@@ -834,6 +862,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
834
862
|
sock.data.watch?.();
|
|
835
863
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
836
864
|
if (isWsPeer(peer)) {
|
|
865
|
+
if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
|
|
837
866
|
peer.detach(sock);
|
|
838
867
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
839
868
|
}
|
|
@@ -1399,12 +1428,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1399
1428
|
reviewOnly: reviewOnlyPeer,
|
|
1400
1429
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1401
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
|
+
},
|
|
1402
1435
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1403
1436
|
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1404
1437
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1405
1438
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1406
1439
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
1407
|
-
const bound =
|
|
1440
|
+
const bound = currentExecutionOwner(id);
|
|
1408
1441
|
return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
|
|
1409
1442
|
})),
|
|
1410
1443
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
@@ -1413,6 +1446,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1413
1446
|
profile: profileName(config.profile),
|
|
1414
1447
|
profileData: resolvedProfile,
|
|
1415
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),
|
|
1416
1452
|
catalog,
|
|
1417
1453
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1418
1454
|
projectedReview: task => reviewProjection(task),
|
|
@@ -1548,7 +1584,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1548
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) });
|
|
1549
1585
|
const worktreeFacts = new Map<string, Facts>();
|
|
1550
1586
|
const worktreeProjections = new Map<string, TextProjections>();
|
|
1551
|
-
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
|
+
};
|
|
1552
1591
|
const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
|
|
1553
1592
|
const projectionsFor = (peer: string): TextProjections => {
|
|
1554
1593
|
const root = executionRoot(peer);
|
|
@@ -1850,7 +1889,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1850
1889
|
}
|
|
1851
1890
|
for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
|
|
1852
1891
|
const unreadMoves = [...moves.values()];
|
|
1853
|
-
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
|
|
1854
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.";
|
|
1855
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 } : {}) } }));
|
|
1856
1895
|
// Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
|
|
@@ -1862,8 +1901,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1862
1901
|
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1863
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
|
|
1864
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).
|
|
1865
|
-
|
|
1866
|
-
|
|
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}`); } };
|
|
1867
1926
|
startupCleanup.push(() => budget.close());
|
|
1868
1927
|
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1869
1928
|
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
@@ -1962,13 +2021,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1962
2021
|
function privateSnapshotWindow(): boolean {
|
|
1963
2022
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1964
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
|
+
}
|
|
1965
2060
|
function ownerWriteRoot(peer: string): string | undefined {
|
|
1966
2061
|
if (declarations[peer]) {
|
|
1967
|
-
const bound =
|
|
2062
|
+
const bound = currentExecutionOwner(peer);
|
|
1968
2063
|
if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
|
|
1969
2064
|
}
|
|
1970
2065
|
return executionRoot(peer);
|
|
1971
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
|
+
}
|
|
1972
2088
|
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
1973
2089
|
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
1974
2090
|
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
@@ -2027,30 +2143,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2027
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 + "/")); });
|
|
2028
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 ?? []) });
|
|
2029
2145
|
}
|
|
2030
|
-
|
|
2031
|
-
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2146
|
+
/** Tokens a peer used, as increments. */
|
|
2032
2147
|
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
2033
|
-
const addTokens = (peer: PeerId, n: number): number => {
|
|
2148
|
+
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
2034
2149
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
2035
2150
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
2036
2151
|
if (n > 0) {
|
|
2037
|
-
event({ type: "tokens", peer, n, ...taskAttribution(peer) });
|
|
2152
|
+
event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
|
|
2038
2153
|
const turn = turns.get(peer);
|
|
2039
2154
|
if (turn) turn.tokens += n;
|
|
2040
2155
|
}
|
|
2041
2156
|
return n;
|
|
2042
2157
|
};
|
|
2043
|
-
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
2044
|
-
const n = addTokens(peer, delta(peer, total, session));
|
|
2045
|
-
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
2046
|
-
const now = Date.now();
|
|
2047
|
-
kimiTokens.push({ at: now, n });
|
|
2048
|
-
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
2049
|
-
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
2050
|
-
budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
2051
|
-
};
|
|
2052
2158
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
2053
2159
|
let claudeUsageSeen = 0;
|
|
2160
|
+
let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
|
|
2161
|
+
let bandEvaluations = 0; // evaluations that ran (not skipped for an unchanged board); read by tests through the daemon
|
|
2054
2162
|
const claudeContextSeen = new Map<string, string>();
|
|
2055
2163
|
const contextTailSeen = new Map<PeerId, string>();
|
|
2056
2164
|
const retainedCliAudits = new Set<string>();
|
|
@@ -2077,7 +2185,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2077
2185
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
2078
2186
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
2079
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 });
|
|
2080
|
-
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);
|
|
2081
2189
|
feedBudgetPauses();
|
|
2082
2190
|
supervision.checkRound();
|
|
2083
2191
|
try {
|
|
@@ -2109,6 +2217,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2109
2217
|
}, 1000),
|
|
2110
2218
|
setInterval(() => budget.tick(), 30_000),
|
|
2111
2219
|
setInterval(pollUsageRollup, 10_000),
|
|
2220
|
+
// #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
|
|
2221
|
+
// revision moves on every task write), so an idle board costs one integer read. Held while stopping or recovering, like the task sweep.
|
|
2222
|
+
// ponytail: one evaluation reads every task with its history (a few ms per hundred tasks); bound it by a history window if a board passes several thousand tasks.
|
|
2223
|
+
setInterval(() => {
|
|
2224
|
+
if (stopping || recoveryActive()) return;
|
|
2225
|
+
try {
|
|
2226
|
+
const state = tasks.bandsState();
|
|
2227
|
+
if (!state.ready || opts.bandsHeld?.()) return; // held, mode off or no bands: nothing is consumed, so the breach is still there when it clears
|
|
2228
|
+
const revision = `${board.statusRevision()}:${state.signature}`;
|
|
2229
|
+
if (revision === bandsRevision) return;
|
|
2230
|
+
bandEvaluations++;
|
|
2231
|
+
tasks.proposeFromBands();
|
|
2232
|
+
bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
|
|
2233
|
+
} catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
|
|
2234
|
+
}, opts.bandsEveryMs ?? 300_000),
|
|
2112
2235
|
setInterval(() => {
|
|
2113
2236
|
if (!ready || !memoryBinding) return;
|
|
2114
2237
|
const before = memoryProblem;
|
|
@@ -2234,7 +2357,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2234
2357
|
}
|
|
2235
2358
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2236
2359
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
2237
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2360
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2238
2361
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2239
2362
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2240
2363
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -2364,6 +2487,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2364
2487
|
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2365
2488
|
case "hub_sdlc_costs":
|
|
2366
2489
|
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2490
|
+
case "hub_sdlc_metrics":
|
|
2491
|
+
return JSON.stringify(tasks.sdlcMetrics(by, a.since));
|
|
2367
2492
|
case "hub_plan_review": {
|
|
2368
2493
|
const { id, ...review } = a;
|
|
2369
2494
|
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
@@ -2384,10 +2509,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2384
2509
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
2385
2510
|
case "hub_task_propose": {
|
|
2386
2511
|
const t = await tasks.propose(by, a);
|
|
2387
|
-
|
|
2512
|
+
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
2513
|
+
if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
|
|
2388
2514
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
2389
2515
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
2390
|
-
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2516
|
+
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
|
|
2391
2517
|
}
|
|
2392
2518
|
case "hub_task_accept": {
|
|
2393
2519
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
@@ -2409,7 +2535,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2409
2535
|
case "hub_task_decline":
|
|
2410
2536
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
2411
2537
|
case "hub_task_done": {
|
|
2412
|
-
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);
|
|
2413
2539
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
2414
2540
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
2415
2541
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -2423,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2423
2549
|
}
|
|
2424
2550
|
case "hub_sdlc_record":
|
|
2425
2551
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2426
|
-
return line(tasks.
|
|
2552
|
+
return line(await tasks.recordSdlc(by, a.id, a.receipt));
|
|
2427
2553
|
case "hub_review":
|
|
2428
2554
|
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2429
2555
|
case "hub_remember":
|
|
@@ -2466,7 +2592,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2466
2592
|
const task = board.get(Number(a.id));
|
|
2467
2593
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2468
2594
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2469
|
-
|
|
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 });
|
|
2470
2597
|
}
|
|
2471
2598
|
if (op === "route_explain" && by !== USER) {
|
|
2472
2599
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2502,7 +2629,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2502
2629
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2503
2630
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2504
2631
|
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2505
|
-
|
|
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);
|
|
2506
2634
|
}
|
|
2507
2635
|
case "task_assign":
|
|
2508
2636
|
case "task_assign_checked": {
|
|
@@ -2587,6 +2715,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2587
2715
|
}
|
|
2588
2716
|
};
|
|
2589
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
|
+
};
|
|
2590
2742
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2591
2743
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2592
2744
|
const unattendedPeers = new Set<string>();
|
|
@@ -2606,8 +2758,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2606
2758
|
owner: row => {
|
|
2607
2759
|
const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
|
|
2608
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;
|
|
2609
2763
|
return { ...proof, isCurrent: () => {
|
|
2610
|
-
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;
|
|
2611
2765
|
const current = owner?.terminalOwner?.(row.launchId);
|
|
2612
2766
|
return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
|
|
2613
2767
|
} };
|
|
@@ -2680,8 +2834,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2680
2834
|
if (value.instanceId !== instanceId) return {};
|
|
2681
2835
|
let currentLauncher = false;
|
|
2682
2836
|
try {
|
|
2683
|
-
const launch =
|
|
2684
|
-
if (launch
|
|
2837
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2838
|
+
if (launch) {
|
|
2685
2839
|
currentLauncher = true;
|
|
2686
2840
|
if (value.launchId !== launch.launchId) return {};
|
|
2687
2841
|
}
|
|
@@ -2780,7 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2780
2934
|
// filled one stays in the row and never matches.
|
|
2781
2935
|
const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
|
|
2782
2936
|
const integrity = (older = 0) => {
|
|
2783
|
-
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)]));
|
|
2784
2938
|
const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
|
|
2785
2939
|
const row: Record<string, unknown> = { ...t };
|
|
2786
2940
|
for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
|
|
@@ -2859,7 +3013,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2859
3013
|
instanceId,
|
|
2860
3014
|
version: VERSION,
|
|
2861
3015
|
pluginBundle: SERVER_JS,
|
|
2862
|
-
pendingRestart,
|
|
3016
|
+
pendingRestart: allPendingRestart(),
|
|
2863
3017
|
retainedConfig,
|
|
2864
3018
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2865
3019
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
@@ -2878,6 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2878
3032
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2879
3033
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2880
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"])),
|
|
2881
3037
|
stopping,
|
|
2882
3038
|
pid: process.pid,
|
|
2883
3039
|
cwd: opts.cwd,
|
|
@@ -2885,11 +3041,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2885
3041
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2886
3042
|
codexProxyPort: opts.codexProxyPort,
|
|
2887
3043
|
peers: Object.fromEntries(
|
|
2888
|
-
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), ...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 ?? [] } : {}) }]; }),
|
|
2889
3045
|
),
|
|
2890
3046
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2891
3047
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2892
|
-
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)]; })),
|
|
2893
3049
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2894
3050
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2895
3051
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -2943,7 +3099,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2943
3099
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
2944
3100
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
2945
3101
|
function heldRows(peer: PeerId) {
|
|
2946
|
-
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 => ({
|
|
2947
3103
|
id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
|
|
2948
3104
|
kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
|
|
2949
3105
|
context: deliveryTaskContext(row.originals, board, peer),
|
|
@@ -2959,7 +3115,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2959
3115
|
if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
2960
3116
|
settlingSuperseded = true;
|
|
2961
3117
|
try {
|
|
2962
|
-
for (const row of bus.queueList()) {
|
|
3118
|
+
for (const row of bus.queueList(undefined, true)) {
|
|
2963
3119
|
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
2964
3120
|
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
2965
3121
|
if (!reason) continue;
|
|
@@ -3260,7 +3416,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3260
3416
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3261
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 } : {}) });
|
|
3262
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 */ }
|
|
3263
3420
|
} else if (!busy && open) {
|
|
3421
|
+
collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
|
|
3264
3422
|
turns.delete(e.peer);
|
|
3265
3423
|
let afterTurn: (() => void) | undefined;
|
|
3266
3424
|
let files: number | undefined;
|
|
@@ -3348,6 +3506,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3348
3506
|
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
3349
3507
|
}
|
|
3350
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);
|
|
3351
3513
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3352
3514
|
else offlineSince.delete(e.peer);
|
|
3353
3515
|
// A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
|
|
@@ -3434,7 +3596,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3434
3596
|
catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
|
|
3435
3597
|
};
|
|
3436
3598
|
if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
|
|
3437
|
-
|
|
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 }; }
|
|
3438
3601
|
const id = randomUUID().slice(0, 8);
|
|
3439
3602
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
3440
3603
|
// could repaint the terminal line, so everything but newline and tab is made visible.
|
|
@@ -3650,7 +3813,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3650
3813
|
const owner = bus.peers.get(peer);
|
|
3651
3814
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3652
3815
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3653
|
-
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" };
|
|
3654
3817
|
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3655
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` };
|
|
3656
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" };
|
|
@@ -3826,9 +3989,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3826
3989
|
}
|
|
3827
3990
|
if (adapter?.start.acpCommand) {
|
|
3828
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
|
|
3829
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"]({
|
|
3830
3994
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3831
3995
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3996
|
+
actionGate: acpActionGate(peer),
|
|
3832
3997
|
cmd, kind: adapter.kind,
|
|
3833
3998
|
permissionMode: permissionMode(peer),
|
|
3834
3999
|
...(args.model ? { launchModel: args.model } : {}),
|
|
@@ -3842,7 +4007,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3842
4007
|
// session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
|
|
3843
4008
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3844
4009
|
log,
|
|
3845
|
-
onTokens: (total, session) => onKimiTokens(total, session, peer),
|
|
3846
4010
|
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
3847
4011
|
onQuotaRefusal: (window) => {
|
|
3848
4012
|
budget.report(peer, [window], true);
|
|
@@ -3853,6 +4017,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3853
4017
|
// adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
|
|
3854
4018
|
// shape reports a null reading: unknown, not the previous value (#285).
|
|
3855
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
|
|
3856
4021
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3857
4022
|
const current = contextSession(peer);
|
|
3858
4023
|
if (!current) return;
|
|
@@ -3878,9 +4043,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3878
4043
|
userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
|
|
3879
4044
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3880
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
|
|
3881
4047
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3882
4048
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3883
4049
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
4050
|
+
actionGate: acpActionGate(peer),
|
|
3884
4051
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3885
4052
|
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3886
4053
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
@@ -4230,6 +4397,51 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4230
4397
|
await local.start();
|
|
4231
4398
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
4232
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
|
+
}
|
|
4233
4445
|
if (adapter?.reviewer) {
|
|
4234
4446
|
const backend = adapter.reviewer.backend;
|
|
4235
4447
|
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
@@ -4262,7 +4474,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4262
4474
|
onUnavailable: (failure, current) => {
|
|
4263
4475
|
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
4264
4476
|
if (!attached()) return;
|
|
4265
|
-
if (failure.cause === "start_failed")
|
|
4477
|
+
if (failure.cause === "start_failed") {
|
|
4478
|
+
// #722: the class (a closed list) is the only detail; never a path, native text or token.
|
|
4479
|
+
const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
|
|
4480
|
+
log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
|
|
4481
|
+
reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
|
|
4482
|
+
}
|
|
4266
4483
|
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
4267
4484
|
},
|
|
4268
4485
|
log,
|
|
@@ -4272,7 +4489,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4272
4489
|
await reviewer.stop().catch(() => {});
|
|
4273
4490
|
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4274
4491
|
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4275
|
-
:
|
|
4492
|
+
: reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
|
|
4493
|
+
log(`start ${peer} failed [cause: ${error instanceof ReviewerUnavailableError ? error.causeClass : "unknown"}]`);
|
|
4276
4494
|
reviewerUnavailable.set(peer, reason);
|
|
4277
4495
|
return { ok: false, error: reason };
|
|
4278
4496
|
}
|
|
@@ -4432,7 +4650,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4432
4650
|
const text = (v: unknown, max = 200) => typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined;
|
|
4433
4651
|
const tier = q.tier === "short" || q.tier === "long" || q.tier === "goals" ? q.tier : "long";
|
|
4434
4652
|
const kind = typeof q.kind === "string" && (["decision", "finding", "contract", "fail"] as string[]).includes(q.kind) ? q.kind as MemoryView["kind"] : undefined;
|
|
4435
|
-
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4653
|
+
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node, typeof q.node === "string" && q.node.startsWith("symbol:") ? 512 : 200), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4436
4654
|
const hops = q.hops === 2 ? 2 : 1;
|
|
4437
4655
|
const all = memoryStore.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER });
|
|
4438
4656
|
const goals = memoryStore.goals(memoryProject);
|
|
@@ -4470,13 +4688,50 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4470
4688
|
const attentionStatus = status(false, true);
|
|
4471
4689
|
let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
|
|
4472
4690
|
const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
|
|
4691
|
+
const usageView = (open: boolean) => {
|
|
4692
|
+
// #683 U4: the roll-up runs only while the Agents view that shows it is open, never on a hidden view's 1 s poll.
|
|
4693
|
+
if (!open) return undefined;
|
|
4694
|
+
try {
|
|
4695
|
+
const caught = usageRollup.catchUp(100);
|
|
4696
|
+
const status = budget.status();
|
|
4697
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
4698
|
+
const limits = usageRollup.limits(undefined, { limit: 100, live });
|
|
4699
|
+
const total = usageRollup.total();
|
|
4700
|
+
const daily = usageRollup.daily(14);
|
|
4701
|
+
const taskUsage = usageRollup.query({ by: "task" });
|
|
4702
|
+
return {
|
|
4703
|
+
// `behind`: the roll-up has not folded the whole log, so every figure below is a lower bound, never the whole (unknown is not 0).
|
|
4704
|
+
behind: !caught,
|
|
4705
|
+
daily,
|
|
4706
|
+
limits,
|
|
4707
|
+
total,
|
|
4708
|
+
tasks: taskUsage.rows.map(r => ({
|
|
4709
|
+
id: r.key === "unattributed" ? 0 : Number(r.key.replace(/^#/, "")),
|
|
4710
|
+
pii: r.pii === true,
|
|
4711
|
+
records: r.records,
|
|
4712
|
+
input: r.input,
|
|
4713
|
+
output: r.output,
|
|
4714
|
+
cacheRead: r.cacheRead,
|
|
4715
|
+
cacheWrite: r.cacheWrite,
|
|
4716
|
+
reasoning: r.reasoning,
|
|
4717
|
+
total: r.total,
|
|
4718
|
+
counted: r.counted,
|
|
4719
|
+
totalWithoutCache: r.totalWithoutCache,
|
|
4720
|
+
})),
|
|
4721
|
+
};
|
|
4722
|
+
} catch (e) {
|
|
4723
|
+
log(`dashboard usage view failed: ${(e as Error).message}`);
|
|
4724
|
+
// The page keeps the last figures for `undefined` (a hidden view), so a failed read says so explicitly instead.
|
|
4725
|
+
return { unavailable: true };
|
|
4726
|
+
}
|
|
4727
|
+
};
|
|
4473
4728
|
return {
|
|
4474
4729
|
ok: true,
|
|
4475
4730
|
projectId,
|
|
4476
4731
|
instanceId,
|
|
4477
4732
|
attention: attentionStatus.attentionInput,
|
|
4478
4733
|
pet,
|
|
4479
|
-
status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4734
|
+
status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4480
4735
|
boardCapabilities: { backlog: true },
|
|
4481
4736
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
4482
4737
|
releases: tasks.releaseList(USER),
|
|
@@ -4489,7 +4744,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4489
4744
|
tasks: boardTasks.map((task) => {
|
|
4490
4745
|
const view = tasks.publicView(task);
|
|
4491
4746
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4492
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4747
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4493
4748
|
}),
|
|
4494
4749
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4495
4750
|
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
@@ -4508,6 +4763,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4508
4763
|
roster: rosterView(),
|
|
4509
4764
|
...benchView(),
|
|
4510
4765
|
...memoryView(_input?.memory),
|
|
4766
|
+
usage: usageView(_input?.view === "agents"),
|
|
4511
4767
|
};
|
|
4512
4768
|
}
|
|
4513
4769
|
|
|
@@ -4604,7 +4860,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4604
4860
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4605
4861
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4606
4862
|
}
|
|
4607
|
-
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) };
|
|
4608
4864
|
const decision = authorizeConfigLeaf(input);
|
|
4609
4865
|
if (!decision.allowed) {
|
|
4610
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" : ""}`;
|
|
@@ -4784,7 +5040,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4784
5040
|
/** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
|
|
4785
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.
|
|
4786
5042
|
function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
|
|
4787
|
-
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
5043
|
+
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
|
|
4788
5044
|
catch { return {}; }
|
|
4789
5045
|
}
|
|
4790
5046
|
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
|
|
@@ -4806,7 +5062,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4806
5062
|
});
|
|
4807
5063
|
const undo = pendingUndo(opts.stateDir);
|
|
4808
5064
|
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
4809
|
-
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 } : {}) };
|
|
4810
5066
|
}
|
|
4811
5067
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
4812
5068
|
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
@@ -5044,6 +5300,46 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5044
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."] };
|
|
5045
5301
|
}
|
|
5046
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
|
+
|
|
5047
5343
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
5048
5344
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
5049
5345
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -5129,6 +5425,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5129
5425
|
return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
|
|
5130
5426
|
case "setting_reload":
|
|
5131
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);
|
|
5132
5433
|
default:
|
|
5133
5434
|
return bad;
|
|
5134
5435
|
}
|
|
@@ -5175,6 +5476,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5175
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";
|
|
5176
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";
|
|
5177
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";
|
|
5178
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";
|
|
5179
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";
|
|
5180
5482
|
return undefined;
|
|
@@ -5296,11 +5598,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5296
5598
|
}
|
|
5297
5599
|
|
|
5298
5600
|
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
5302
|
-
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
5303
|
-
} catch { return undefined; }
|
|
5601
|
+
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
5602
|
+
return row?.permissionHook ? row : undefined;
|
|
5304
5603
|
}
|
|
5305
5604
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5306
5605
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
@@ -5410,7 +5709,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5410
5709
|
|
|
5411
5710
|
function onMessage(sock: Sock, msg: any): void {
|
|
5412
5711
|
const c = sock.data;
|
|
5413
|
-
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
|
+
};
|
|
5414
5716
|
if (!c.authed) {
|
|
5415
5717
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
5416
5718
|
if (msg.v !== PROTOCOL) {
|
|
@@ -5431,6 +5733,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5431
5733
|
c.peer = String(msg.peer ?? "");
|
|
5432
5734
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5433
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
|
+
}
|
|
5434
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
|
|
5435
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.
|
|
5436
5745
|
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
@@ -5447,8 +5756,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5447
5756
|
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
5448
5757
|
let currentLaunch: unknown;
|
|
5449
5758
|
try {
|
|
5450
|
-
const launch =
|
|
5451
|
-
if (launch
|
|
5759
|
+
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5760
|
+
if (launch) currentLaunch = launch.launchId;
|
|
5452
5761
|
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
5453
5762
|
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
5454
5763
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
@@ -5464,6 +5773,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5464
5773
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
5465
5774
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
5466
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
|
+
}
|
|
5467
5787
|
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
|
|
5468
5788
|
if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
|
|
5469
5789
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
@@ -5478,7 +5798,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5478
5798
|
}
|
|
5479
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" });
|
|
5480
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" });
|
|
5481
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
|
+
}
|
|
5482
5819
|
case "permission_default": {
|
|
5483
5820
|
if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
|
|
5484
5821
|
if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
|
|
@@ -5554,6 +5891,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5554
5891
|
return void reply({ ok: true });
|
|
5555
5892
|
}
|
|
5556
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
|
+
}
|
|
5557
5906
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5558
5907
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5559
5908
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
@@ -5722,6 +6071,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5722
6071
|
};
|
|
5723
6072
|
try {
|
|
5724
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
|
+
}
|
|
5725
6089
|
let nativeClaude = false;
|
|
5726
6090
|
if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
|
|
5727
6091
|
let expectedLaunch: string | undefined;
|
|
@@ -5730,8 +6094,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5730
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;
|
|
5731
6095
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
5732
6096
|
try {
|
|
5733
|
-
const launch =
|
|
5734
|
-
if (launch
|
|
6097
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6098
|
+
if (launch) expectedLaunch = launch.launchId;
|
|
5735
6099
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
5736
6100
|
const current = claudeSession(peer);
|
|
5737
6101
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
@@ -5748,10 +6112,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5748
6112
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
5749
6113
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5750
6114
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5751
|
-
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;
|
|
5752
6118
|
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5753
|
-
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5754
|
-
|
|
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 } : {}) } : {};
|
|
5755
6123
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5756
6124
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5757
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")));
|
|
@@ -5984,7 +6352,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5984
6352
|
case "ui_snapshot":
|
|
5985
6353
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
5986
6354
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
5987
|
-
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined ? { memory: msg.memory } : undefined));
|
|
6355
|
+
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
|
|
5988
6356
|
case "ui_action":
|
|
5989
6357
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
5990
6358
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -5999,7 +6367,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5999
6367
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
6000
6368
|
case "settings_get":
|
|
6001
6369
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6002
|
-
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 }; })() });
|
|
6003
6371
|
case "settings_reload":
|
|
6004
6372
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
6005
6373
|
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
@@ -6013,6 +6381,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6013
6381
|
case "settings_undo":
|
|
6014
6382
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6015
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;
|
|
6016
6394
|
case "kimi_launch_end": {
|
|
6017
6395
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
6018
6396
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -6062,8 +6440,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6062
6440
|
});
|
|
6063
6441
|
return;
|
|
6064
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" });
|
|
6065
6444
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
6066
|
-
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(
|
|
6067
6446
|
(text) => reply({ t: "task", ok: true, text }),
|
|
6068
6447
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
6069
6448
|
);
|
|
@@ -6323,7 +6702,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6323
6702
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
6324
6703
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
6325
6704
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6326
|
-
return { bus, token, port: server.port as number, 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)) };
|
|
6327
6706
|
} finally {
|
|
6328
6707
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6329
6708
|
}
|