@staix/agent-hub 0.12.35 → 0.12.36
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 +14 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/tasks.md +11 -1
- package/docs/agent-notes/tests.md +5 -1
- package/docs/operations.md +40 -16
- package/docs/quickstart.md +6 -8
- package/docs/sdlc.md +46 -5
- package/docs/security.md +5 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +18 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +385 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/registry-types.ts +8 -2
- package/src/adapters/registry.ts +13 -1
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +38 -4
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +29 -7
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +181 -73
- package/src/hub/hub-tools.ts +20 -1
- package/src/hub/instances.ts +17 -3
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/routing.ts +17 -2
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +544 -26
- package/src/hub/usage.ts +57 -20
package/src/hub/daemon.ts
CHANGED
|
@@ -14,12 +14,14 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
|
14
14
|
import { loadPetConfig } from "../pet/config.ts";
|
|
15
15
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
16
16
|
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
17
|
+
import { buildReviewBrief } from "./review-brief.ts";
|
|
17
18
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
18
19
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
19
20
|
import { ActivityRing } from "./activity.ts";
|
|
20
21
|
import { JcodePeer } from "../adapters/jcode.ts";
|
|
21
22
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
22
|
-
import { loadSdlc, sdlcGh } from "./sdlc.ts";
|
|
23
|
+
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
24
|
+
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
23
25
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
24
26
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
25
27
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
@@ -46,7 +48,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
|
|
|
46
48
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
47
49
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
48
50
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
49
|
-
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
51
|
+
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
50
52
|
import type { ServerWebSocket } from "bun";
|
|
51
53
|
import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
52
54
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -61,6 +63,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
61
63
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
62
64
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
63
65
|
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
66
|
+
import { reviewDiff, ClaudePrintPeer } from "../adapters/claude-print.ts";
|
|
64
67
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
65
68
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
66
69
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
@@ -69,12 +72,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
|
|
|
69
72
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
70
73
|
import { HUB } from "./envelope.ts";
|
|
71
74
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
72
|
-
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
75
|
+
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
73
76
|
import { basename, isAbsolute } from "node:path";
|
|
74
77
|
import { fileURLToPath } from "node:url";
|
|
75
78
|
import { realPath, hubHome } from "./project.ts";
|
|
76
79
|
import type { BusEvent } from "./bus.ts";
|
|
77
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
80
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
78
81
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
79
82
|
import { SupervisionFeed } from "./supervision.ts";
|
|
80
83
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -603,6 +606,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
603
606
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
604
607
|
const declarations = validateInstances(loadedConfig.instances);
|
|
605
608
|
const extraIds = Object.keys(declarations);
|
|
609
|
+
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
610
|
+
const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
|
|
611
|
+
// Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
|
|
612
|
+
const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
|
|
606
613
|
const executionRoots = new Map<string, string>();
|
|
607
614
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
608
615
|
const roles = { ...loadedConfig.roles };
|
|
@@ -611,7 +618,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
611
618
|
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
612
619
|
for (const [id, declaration] of Object.entries(declarations)) {
|
|
613
620
|
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
614
|
-
startModesFromInstances[id] = { start_mode: "headless" };
|
|
621
|
+
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
615
622
|
if (declaration.permission_mode !== undefined) {
|
|
616
623
|
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
617
624
|
instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
@@ -627,10 +634,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
627
634
|
const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
|
|
628
635
|
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
629
636
|
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
630
|
-
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
|
|
637
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
631
638
|
const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
|
|
632
|
-
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer)?.start.conductorStartable === true;
|
|
633
|
-
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds]);
|
|
639
|
+
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
640
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
641
|
+
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
642
|
+
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
643
|
+
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
644
|
+
const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
|
|
645
|
+
/** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
|
|
646
|
+
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
647
|
+
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
648
|
+
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
634
649
|
const SETTINGS = projectSettings(declarations);
|
|
635
650
|
const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
|
|
636
651
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
@@ -872,6 +887,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
872
887
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
873
888
|
*/
|
|
874
889
|
const silence = (env: Envelope, to: PeerId): string | undefined => {
|
|
890
|
+
if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
|
|
875
891
|
if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
|
|
876
892
|
const cohort = tasks.silenced(env.from, to);
|
|
877
893
|
if (!cohort) return undefined;
|
|
@@ -914,7 +930,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
914
930
|
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
915
931
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
916
932
|
const requestedPeerStops = new Set<string>();
|
|
917
|
-
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
933
|
+
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
918
934
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
919
935
|
startupCleanup.push(() => bus.closeJournal());
|
|
920
936
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -1080,7 +1096,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1080
1096
|
|
|
1081
1097
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1082
1098
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1083
|
-
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
|
|
1099
|
+
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
|
|
1084
1100
|
// The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
|
|
1085
1101
|
let own: string | undefined;
|
|
1086
1102
|
try {
|
|
@@ -1278,7 +1294,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1278
1294
|
};
|
|
1279
1295
|
const tasks: Tasks = new Tasks({
|
|
1280
1296
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1297
|
+
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1298
|
+
const codes: ("approvals" | "quota")[] = [];
|
|
1299
|
+
if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
|
|
1300
|
+
if (budget.record(peer)) codes.push("quota");
|
|
1301
|
+
return [peer, codes];
|
|
1302
|
+
})),
|
|
1281
1303
|
instances: () => declarations,
|
|
1304
|
+
reviewOnly: reviewOnlyPeer,
|
|
1305
|
+
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1306
|
+
reviewAuthority: (by, task) => {
|
|
1307
|
+
if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
|
|
1308
|
+
const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
|
|
1309
|
+
return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
|
|
1310
|
+
},
|
|
1311
|
+
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1312
|
+
reviewContext: (by, task) => definitionOf(by)?.start.daemonPort === "claude-print" && (bus.peers.get(by) as ClaudePrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1282
1313
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1283
1314
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1284
1315
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
@@ -1294,6 +1325,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1294
1325
|
catalog,
|
|
1295
1326
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1296
1327
|
projectedReview: task => reviewProjection(task),
|
|
1328
|
+
reviewBrief: task => {
|
|
1329
|
+
const root = executionRoot(task.owner ?? "");
|
|
1330
|
+
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1331
|
+
},
|
|
1297
1332
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1298
1333
|
board,
|
|
1299
1334
|
bus,
|
|
@@ -1547,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1547
1582
|
} catch { /* no transcript yet: no readback */ }
|
|
1548
1583
|
};
|
|
1549
1584
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1550
|
-
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
|
|
1551
|
-
const reported = claudeSession();
|
|
1585
|
+
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
|
|
1586
|
+
const reported = claudeSession(peer);
|
|
1552
1587
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1553
1588
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
1554
1589
|
try {
|
|
@@ -1572,6 +1607,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1572
1607
|
const activeAt = new Map<PeerId, number>();
|
|
1573
1608
|
interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
|
|
1574
1609
|
const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
|
|
1610
|
+
const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
|
|
1575
1611
|
const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
|
|
1576
1612
|
/** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
|
|
1577
1613
|
const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
|
|
@@ -1615,7 +1651,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1615
1651
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1616
1652
|
const p = bus.peers.get(peer);
|
|
1617
1653
|
if (p instanceof WsPeer && p.claiming) return undefined;
|
|
1618
|
-
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1654
|
+
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1619
1655
|
if (p instanceof CodexPeer) return p.thread || undefined;
|
|
1620
1656
|
if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1621
1657
|
return undefined;
|
|
@@ -1829,7 +1865,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1829
1865
|
};
|
|
1830
1866
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
1831
1867
|
let claudeUsageSeen = 0;
|
|
1832
|
-
|
|
1868
|
+
const claudeContextSeen = new Map<string, string>();
|
|
1833
1869
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1834
1870
|
const intervals = [
|
|
1835
1871
|
setInterval(() => {
|
|
@@ -1861,16 +1897,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1861
1897
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
|
|
1862
1898
|
}
|
|
1863
1899
|
}
|
|
1864
|
-
try {
|
|
1865
|
-
const file = join(opts.stateDir, "claude-context
|
|
1900
|
+
for (const claudePeer of channelPeers) try {
|
|
1901
|
+
const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
|
|
1866
1902
|
const payload = readFileSync(file, "utf8");
|
|
1867
|
-
if (payload === claudeContextSeen)
|
|
1903
|
+
if (payload === claudeContextSeen.get(claudePeer)) continue;
|
|
1868
1904
|
const c = JSON.parse(payload);
|
|
1869
|
-
const session = claudeSession();
|
|
1870
|
-
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(
|
|
1871
|
-
claudeContextSeen
|
|
1872
|
-
const bound = contextSession(
|
|
1873
|
-
if (bound) contexts.report(
|
|
1905
|
+
const session = claudeSession(claudePeer);
|
|
1906
|
+
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
1907
|
+
claudeContextSeen.set(claudePeer, payload);
|
|
1908
|
+
const bound = contextSession(claudePeer);
|
|
1909
|
+
if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
|
|
1874
1910
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
1875
1911
|
}, 1000),
|
|
1876
1912
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -1925,7 +1961,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1925
1961
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
1926
1962
|
// What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
|
|
1927
1963
|
// names are answered by the hub; every call still passes daemon role/capability checks before its effects.
|
|
1928
|
-
const HUB_TOOLS = ["hub_send", ...
|
|
1964
|
+
const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
|
|
1929
1965
|
const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
|
|
1930
1966
|
const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
1931
1967
|
|
|
@@ -1980,11 +2016,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1980
2016
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
1981
2017
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
1982
2018
|
if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
|
|
1983
|
-
|
|
2019
|
+
const result = await conductor.execute(by, op, a);
|
|
2020
|
+
const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
|
|
2021
|
+
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
1984
2022
|
}
|
|
1985
2023
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
1986
2024
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
1987
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || 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`);
|
|
2025
|
+
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_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`);
|
|
1988
2026
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
1989
2027
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
1990
2028
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
@@ -2100,6 +2138,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2100
2138
|
return JSON.stringify(tasks.backlogList(by));
|
|
2101
2139
|
case "hub_release_list":
|
|
2102
2140
|
return JSON.stringify(tasks.releaseList(by));
|
|
2141
|
+
case "hub_planner_groom":
|
|
2142
|
+
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2143
|
+
case "hub_sdlc_costs":
|
|
2144
|
+
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2145
|
+
case "hub_plan_review": {
|
|
2146
|
+
const { id, ...review } = a;
|
|
2147
|
+
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
2148
|
+
}
|
|
2149
|
+
case "hub_backlog_return_untouched":
|
|
2150
|
+
return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
|
|
2103
2151
|
case "hub_backlog_plan":
|
|
2104
2152
|
return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
|
|
2105
2153
|
case "hub_backlog_pull":
|
|
@@ -2120,7 +2168,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2120
2168
|
return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2121
2169
|
}
|
|
2122
2170
|
case "hub_task_accept": {
|
|
2123
|
-
|
|
2171
|
+
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
2172
|
+
const t = await tasks.acceptReviewed(by, a.id, a.plan);
|
|
2124
2173
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
2125
2174
|
const silent = tasks.silentFor(t.id);
|
|
2126
2175
|
const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
|
|
@@ -2151,9 +2200,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2151
2200
|
return extra ? `${result}\n${extra}` : result;
|
|
2152
2201
|
}
|
|
2153
2202
|
case "hub_sdlc_record":
|
|
2203
|
+
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2154
2204
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2155
2205
|
case "hub_review":
|
|
2156
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
|
|
2206
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2157
2207
|
case "hub_remember":
|
|
2158
2208
|
return tasks.remember(by, a);
|
|
2159
2209
|
case "hub_checkpoint": {
|
|
@@ -2193,7 +2243,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2193
2243
|
if (op === "task_show" && by !== USER) {
|
|
2194
2244
|
const task = board.get(Number(a.id));
|
|
2195
2245
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2196
|
-
|
|
2246
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2247
|
+
return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
|
|
2197
2248
|
}
|
|
2198
2249
|
if (op === "route_explain" && by !== USER) {
|
|
2199
2250
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2220,6 +2271,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2220
2271
|
case "backlog_pull":
|
|
2221
2272
|
case "backlog_return":
|
|
2222
2273
|
return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
|
|
2274
|
+
case "review_brief": {
|
|
2275
|
+
const task = board.get(Number(a.id));
|
|
2276
|
+
return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
|
|
2277
|
+
}
|
|
2223
2278
|
case "task_show": {
|
|
2224
2279
|
const task = board.get(Number(a.id));
|
|
2225
2280
|
return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
|
|
@@ -2349,19 +2404,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2349
2404
|
// #64: a Claude session that never persisted a transcript cannot be resumed, so the
|
|
2350
2405
|
// restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
|
|
2351
2406
|
// written before this flag existed are re-derived from disk.
|
|
2352
|
-
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
|
|
2407
|
+
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
|
|
2353
2408
|
if (!unpersistedClaude && !waived()) return false;
|
|
2354
2409
|
}
|
|
2355
2410
|
return true;
|
|
2356
2411
|
});
|
|
2357
2412
|
};
|
|
2358
|
-
|
|
2413
|
+
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2414
|
+
const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
2359
2415
|
try {
|
|
2360
|
-
const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session
|
|
2416
|
+
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2361
2417
|
if (value.instanceId !== instanceId) return {};
|
|
2362
2418
|
let currentLauncher = false;
|
|
2363
2419
|
try {
|
|
2364
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
2420
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
2365
2421
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
|
|
2366
2422
|
currentLauncher = true;
|
|
2367
2423
|
if (value.launchId !== launch.launchId) return {};
|
|
@@ -2369,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2369
2425
|
} catch { /* older/manual launches retain the existing instance fence */ }
|
|
2370
2426
|
try {
|
|
2371
2427
|
const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
2372
|
-
const current =
|
|
2428
|
+
const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
|
|
2373
2429
|
if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
|
|
2374
2430
|
} catch { /* no managed terminal record: the instance fence is still enforced */ }
|
|
2375
2431
|
return {
|
|
@@ -2427,33 +2483,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2427
2483
|
}
|
|
2428
2484
|
/** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
|
|
2429
2485
|
* path Claude itself reported through the status line; fall back to the slug computation. */
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
2434
|
-
return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
|
|
2435
|
-
};
|
|
2486
|
+
// ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
|
|
2487
|
+
const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
|
|
2488
|
+
join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
|
|
2436
2489
|
// Optional native usage: only the instance-fenced explicit Claude session is read.
|
|
2437
2490
|
// IDs persist in telemetry, so a restart/resume never counts the same native message twice.
|
|
2438
2491
|
const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
|
|
2439
|
-
const collectClaudeUsage = () => {
|
|
2440
|
-
const session = claudeSession();
|
|
2441
|
-
if (!session.sessionId || !session.transcriptPath)
|
|
2492
|
+
const collectClaudeUsage = () => { for (const peer of channelPeers) {
|
|
2493
|
+
const session = claudeSession(peer);
|
|
2494
|
+
if (!session.sessionId || !session.transcriptPath) continue;
|
|
2442
2495
|
for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
|
|
2443
2496
|
if (claudeNativeUsageSeen.has(record.id)) continue;
|
|
2444
2497
|
claudeNativeUsageSeen.add(record.id);
|
|
2445
2498
|
const reported = claudeReportedTokens(record.usage);
|
|
2446
|
-
if (reported !== undefined) addTokens(
|
|
2447
|
-
event({ type: "usage", peer
|
|
2499
|
+
if (reported !== undefined) addTokens(peer, reported);
|
|
2500
|
+
event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
2448
2501
|
}
|
|
2449
|
-
};
|
|
2502
|
+
} };
|
|
2450
2503
|
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
2451
2504
|
claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
|
|
2452
2505
|
const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
|
|
2453
2506
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
2454
2507
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
2455
2508
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
2456
|
-
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
|
|
2509
|
+
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
2457
2510
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
2458
2511
|
return [id, row];
|
|
2459
2512
|
}));
|
|
@@ -2496,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2496
2549
|
if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2497
2550
|
if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
|
|
2498
2551
|
if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
|
|
2499
|
-
const launch = claudePermissionLaunch();
|
|
2552
|
+
const launch = claudePermissionLaunch(id);
|
|
2500
2553
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2501
2554
|
}
|
|
2502
2555
|
return permissionMode(id);
|
|
@@ -2508,6 +2561,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2508
2561
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2509
2562
|
};
|
|
2510
2563
|
const lastCodexThreads = new Map<string, string>();
|
|
2564
|
+
// #450 I3: the console and dashboard list each declared instance right after its kind.
|
|
2565
|
+
const byKind = (ids: string[]): string[] => {
|
|
2566
|
+
const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
|
|
2567
|
+
const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
|
|
2568
|
+
return [...out, ...ids.filter(id => !out.includes(id))];
|
|
2569
|
+
};
|
|
2511
2570
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2512
2571
|
const reservations = board.statusReservations();
|
|
2513
2572
|
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
@@ -2519,12 +2578,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2519
2578
|
const taskNeeds = board.statusTasks();
|
|
2520
2579
|
const taskStalls = tasks.stalled();
|
|
2521
2580
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
2522
|
-
const peerIds = statusPeerIds(taskNeeds);
|
|
2581
|
+
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
2523
2582
|
const view = {
|
|
2524
2583
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
2525
2584
|
projectId,
|
|
2526
2585
|
instanceId,
|
|
2527
2586
|
version: VERSION,
|
|
2587
|
+
pluginBundle: SERVER_JS,
|
|
2528
2588
|
pendingRestart,
|
|
2529
2589
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2530
2590
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
@@ -2534,6 +2594,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2534
2594
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2535
2595
|
protocol: PROTOCOL,
|
|
2536
2596
|
features: DAEMON_FEATURES,
|
|
2597
|
+
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2598
|
+
activeInstances: extraIds,
|
|
2537
2599
|
stopping,
|
|
2538
2600
|
pid: process.pid,
|
|
2539
2601
|
cwd: opts.cwd,
|
|
@@ -3173,6 +3235,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3173
3235
|
return { ok: true, mode, opened: opened.via, command };
|
|
3174
3236
|
}
|
|
3175
3237
|
|
|
3238
|
+
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
3239
|
+
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
3240
|
+
|
|
3176
3241
|
async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
|
|
3177
3242
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
3178
3243
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
@@ -3180,9 +3245,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3180
3245
|
const owner = bus.peers.get(peer);
|
|
3181
3246
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3182
3247
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3183
|
-
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3248
|
+
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ClaudePrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3184
3249
|
if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3185
|
-
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with
|
|
3250
|
+
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3186
3251
|
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" };
|
|
3187
3252
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3188
3253
|
try {
|
|
@@ -3199,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3199
3264
|
writeStatus();
|
|
3200
3265
|
const state = bus.stateOf(peer);
|
|
3201
3266
|
if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
|
|
3202
|
-
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with
|
|
3267
|
+
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
|
|
3203
3268
|
return { ok: true, state };
|
|
3204
3269
|
});
|
|
3205
3270
|
} catch {
|
|
@@ -3559,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3559
3624
|
const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
|
|
3560
3625
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
|
|
3561
3626
|
const nativeTurn = pi.observationTurn;
|
|
3562
|
-
const output =
|
|
3627
|
+
const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
|
|
3563
3628
|
...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
|
|
3564
3629
|
permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
|
|
3565
3630
|
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
|
|
@@ -3738,6 +3803,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3738
3803
|
await local.start();
|
|
3739
3804
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
3740
3805
|
}
|
|
3806
|
+
if (adapter?.start.daemonPort === "claude-print") {
|
|
3807
|
+
// #223 R3: one fresh `claude -p` session per review request; idle with no process between reviews.
|
|
3808
|
+
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
3809
|
+
if (existing) { mute(existing); await existing.stop(); }
|
|
3810
|
+
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["claude-print"]({
|
|
3811
|
+
cwd: executionCwd,
|
|
3812
|
+
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
3813
|
+
systemPrompt: sessionRoleContract(peer),
|
|
3814
|
+
watchdogMs: config.watchdog_ms,
|
|
3815
|
+
privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3816
|
+
facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
|
|
3817
|
+
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
3818
|
+
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
3819
|
+
log,
|
|
3820
|
+
}) }) as ClaudePrintPeer;
|
|
3821
|
+
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
3822
|
+
bus.add(reviewer);
|
|
3823
|
+
await reviewer.start();
|
|
3824
|
+
return { ok: true };
|
|
3825
|
+
}
|
|
3741
3826
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
3742
3827
|
}
|
|
3743
3828
|
|
|
@@ -4669,7 +4754,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4669
4754
|
...saved,
|
|
4670
4755
|
queueIds: bus.queueIds(saved.id),
|
|
4671
4756
|
// Recorded at commit, not prepare: a first turn landing in between flips it (#64).
|
|
4672
|
-
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
|
|
4757
|
+
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
|
|
4673
4758
|
}));
|
|
4674
4759
|
const snapshot: RestartSnapshot = {
|
|
4675
4760
|
schemaVersion: 1,
|
|
@@ -4717,9 +4802,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4717
4802
|
return recoveryError("unknown recovery operation");
|
|
4718
4803
|
}
|
|
4719
4804
|
|
|
4720
|
-
function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4805
|
+
function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4721
4806
|
try {
|
|
4722
|
-
const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
4807
|
+
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
4723
4808
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
4724
4809
|
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
4725
4810
|
} catch { return undefined; }
|
|
@@ -4748,7 +4833,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4748
4833
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
4749
4834
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
4750
4835
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
4751
|
-
const launch = claudePermissionLaunch();
|
|
4836
|
+
const launch = claudePermissionLaunch(peer);
|
|
4752
4837
|
if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
4753
4838
|
if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
4754
4839
|
} else if (owner instanceof CodexPeer) {
|
|
@@ -5021,18 +5106,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5021
5106
|
let expectedLaunch: string | undefined;
|
|
5022
5107
|
try {
|
|
5023
5108
|
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
5024
|
-
expectedLaunch = Array.isArray(rows) ? rows.find(row =>
|
|
5109
|
+
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;
|
|
5025
5110
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
5026
5111
|
try {
|
|
5027
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
5112
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
5028
5113
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
|
|
5029
5114
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
5030
|
-
const current = claudeSession();
|
|
5115
|
+
const current = claudeSession(peer);
|
|
5031
5116
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
5032
5117
|
if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
|
|
5033
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5118
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5034
5119
|
const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
|
|
5035
|
-
const file = join(opts.stateDir, "claude-session
|
|
5120
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
5036
5121
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
|
|
5037
5122
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
5038
5123
|
nativeClaude = true;
|
|
@@ -5041,7 +5126,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5041
5126
|
}
|
|
5042
5127
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
5043
5128
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5044
|
-
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
|
|
5129
|
+
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5045
5130
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
5046
5131
|
if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5047
5132
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
@@ -5053,10 +5138,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5053
5138
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
5054
5139
|
if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
|
|
5055
5140
|
const previous = nativeTurnStart.get(peer);
|
|
5056
|
-
const current = claudeSession();
|
|
5141
|
+
const current = claudeSession(peer);
|
|
5057
5142
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
5058
|
-
const session = claudeSession();
|
|
5059
|
-
const baseline = session.sessionId && session.transcriptPath ?
|
|
5143
|
+
const session = claudeSession(peer);
|
|
5144
|
+
const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
|
|
5060
5145
|
nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
|
|
5061
5146
|
}
|
|
5062
5147
|
nativePeer.nativeStarted(sessionId!);
|
|
@@ -5075,12 +5160,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5075
5160
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
5076
5161
|
hookStats.delete(peer);
|
|
5077
5162
|
if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
|
|
5078
|
-
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
|
|
5163
|
+
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
5079
5164
|
};
|
|
5080
5165
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
5081
|
-
const session = claudeSession();
|
|
5082
|
-
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath);
|
|
5083
|
-
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath); return session.sessionId && transcript ?
|
|
5166
|
+
const session = claudeSession(peer);
|
|
5167
|
+
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5168
|
+
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
5084
5169
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
5085
5170
|
const started = nativeTurnStart.get(peer);
|
|
5086
5171
|
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
|
|
@@ -5091,7 +5176,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5091
5176
|
}
|
|
5092
5177
|
if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
5093
5178
|
const scopeCurrent = () => {
|
|
5094
|
-
const now = claudeSession();
|
|
5179
|
+
const now = claudeSession(peer);
|
|
5095
5180
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
5096
5181
|
};
|
|
5097
5182
|
const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
|
|
@@ -5106,14 +5191,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5106
5191
|
if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
|
|
5107
5192
|
if (!result.record) {
|
|
5108
5193
|
const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
|
|
5109
|
-
log(`native Stop refused for claude: ${reason}`);
|
|
5194
|
+
log(`native Stop refused for claude: ${reason}`);
|
|
5195
|
+
// #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
|
|
5196
|
+
if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
|
|
5197
|
+
stopUnconfirmedNoticed.add(started);
|
|
5198
|
+
notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
|
|
5199
|
+
}
|
|
5200
|
+
return;
|
|
5110
5201
|
}
|
|
5111
5202
|
const id = completionId(result.record.id);
|
|
5112
5203
|
if (scopeCurrent() && nativeStopsSeen.has(id)) return;
|
|
5113
5204
|
if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
|
|
5114
5205
|
nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
|
|
5115
5206
|
if (transcript && !session.transcriptPath) {
|
|
5116
|
-
const file = join(opts.stateDir, "claude-session
|
|
5207
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
5117
5208
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
|
|
5118
5209
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
5119
5210
|
}
|
|
@@ -5127,7 +5218,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5127
5218
|
if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
5128
5219
|
if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
|
|
5129
5220
|
factSession(peer, sessionId);
|
|
5130
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5221
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5131
5222
|
if (phase === "post") {
|
|
5132
5223
|
factsFor(peer).postTool(peer, toolUseId, tool, input);
|
|
5133
5224
|
readbacks(peer, transcript);
|
|
@@ -5202,6 +5293,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5202
5293
|
if (msg.op === "export") {
|
|
5203
5294
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5204
5295
|
}
|
|
5296
|
+
if (msg.op === "accept") {
|
|
5297
|
+
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5298
|
+
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
|
5299
|
+
const task = board.get(msg.id);
|
|
5300
|
+
const repo = msg.repo ?? task?.sdlc?.repo;
|
|
5301
|
+
if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
|
|
5302
|
+
const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
|
|
5303
|
+
void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
|
|
5304
|
+
if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
|
|
5305
|
+
// The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
|
|
5306
|
+
const now = board.get(msg.id)?.sdlc;
|
|
5307
|
+
if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
|
|
5308
|
+
try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
|
|
5309
|
+
catch (error) { reply({ ok: false, error: (error as Error).message }); }
|
|
5310
|
+
}, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
|
|
5311
|
+
return;
|
|
5312
|
+
}
|
|
5205
5313
|
if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
|
|
5206
5314
|
const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
|
|
5207
5315
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|