@staix/agent-hub 0.19.0 → 0.20.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 +11 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +4 -3
- package/docs/dogfood.md +4 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +40 -8
- package/docs/quickstart.md +7 -3
- package/docs/sdlc.md +12 -0
- package/docs/security.md +4 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +85 -3
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verified.json +24 -16
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +103 -7
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-implementer.ts +1 -0
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/kit.ts +10 -0
- package/src/adapters/local-worker.ts +2 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/hub/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +86 -79
- package/src/hub/peers.ts +34 -0
- package/src/hub/settings.ts +1 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +139 -7
- package/src/local/deny.ts +21 -0
- package/src/local/sandbox.ts +3 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolN
|
|
|
8
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
9
|
import { normalizeUsage } from "../omniroute/usage.ts";
|
|
10
10
|
import { permissionPayload } from "./permission-payload.ts";
|
|
11
|
-
import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
|
|
11
|
+
import { createClaudeLaunchProof, readClaudeLaunchProof } from "./claude-launch-proof.ts";
|
|
12
12
|
import { InboxPolling } from "./inbox-polling.ts";
|
|
13
13
|
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
14
14
|
import { actionGate, claudeActionClasses } from "./action-enforce.ts";
|
|
@@ -140,7 +140,7 @@ import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
|
140
140
|
import { codeGroup } from "./task-code-group.ts";
|
|
141
141
|
import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
142
142
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
143
|
-
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
143
|
+
import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
|
|
144
144
|
import { MemoryClient } from "../memory/client.ts";
|
|
145
145
|
import { VERSION } from "../version.ts";
|
|
146
146
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
@@ -217,7 +217,7 @@ export interface HubConfig {
|
|
|
217
217
|
/** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
|
|
218
218
|
approvals: { timeout_s: number; notify: boolean };
|
|
219
219
|
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
220
|
-
tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number };
|
|
220
|
+
tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number; /** #785 P1c: pull the top explicitly reserved ready card when a slot frees; machine-local, default off. */ auto_pull?: boolean };
|
|
221
221
|
/** Between-turn task escalation, disabled unless explicitly enabled (#186). */
|
|
222
222
|
task_sweep: TaskSweepConfig;
|
|
223
223
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
@@ -278,7 +278,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
278
278
|
...nativeDefaults.sandbox,
|
|
279
279
|
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
280
280
|
approvals: { timeout_s: 120, notify: false },
|
|
281
|
-
tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER },
|
|
281
|
+
tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER, auto_pull: false },
|
|
282
282
|
task_sweep: DEFAULT_TASK_SWEEP,
|
|
283
283
|
checks: { timeout_s: 600 },
|
|
284
284
|
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
@@ -539,6 +539,8 @@ export interface DaemonOptions {
|
|
|
539
539
|
snapshotBurstMs?: number;
|
|
540
540
|
/** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
|
|
541
541
|
snapshotClock?: () => number;
|
|
542
|
+
/** Host-only bounds of the per-head CI watcher (#785 P1b); tests shorten them, never read from config or a tool. */
|
|
543
|
+
ciWatch?: { deadlineMs?: number; pollMs?: number };
|
|
542
544
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
543
545
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
544
546
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
@@ -1080,6 +1082,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1080
1082
|
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,
|
|
1081
1083
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1082
1084
|
startupCleanup.push(() => bus.closeJournal());
|
|
1085
|
+
const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
|
|
1086
|
+
function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
|
|
1087
|
+
if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
|
|
1088
|
+
return { present: true, port: owner.frontendProxy };
|
|
1089
|
+
}
|
|
1083
1090
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
1084
1091
|
const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
|
|
1085
1092
|
startupCleanup.push(() => conductorHolds.close());
|
|
@@ -1730,9 +1737,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1730
1737
|
review: config.review,
|
|
1731
1738
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1732
1739
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1740
|
+
ciWatch: opts.ciWatch,
|
|
1741
|
+
autoPull: () => config.tasks.auto_pull === true,
|
|
1733
1742
|
cancellationHeld: () => stopping || recoveryActive(),
|
|
1734
1743
|
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1735
|
-
offerPullOnly: (id) => { const peer = bus.peers.get(id); return
|
|
1744
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer?.pullOnly === true; },
|
|
1736
1745
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1737
1746
|
roles: config.roles,
|
|
1738
1747
|
turnFree,
|
|
@@ -2011,9 +2020,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2011
2020
|
const p = bus.peers.get(peer);
|
|
2012
2021
|
if (isWsPeer(p) && p.claiming) return undefined;
|
|
2013
2022
|
if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
return undefined;
|
|
2023
|
+
const session = p?.nativeSession;
|
|
2024
|
+
return typeof session === "string" ? session : undefined;
|
|
2017
2025
|
};
|
|
2018
2026
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
2019
2027
|
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
|
|
@@ -2660,8 +2668,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2660
2668
|
taskOpsInFlight--;
|
|
2661
2669
|
}
|
|
2662
2670
|
};
|
|
2671
|
+
const deliveryRetryAllowed = (owner: PeerAdapter | undefined): boolean => !owner || !("deliveryOnly" in owner) || owner.deliveryOnly === false;
|
|
2663
2672
|
function ownHeldHint(peer: PeerId): string {
|
|
2664
|
-
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(),
|
|
2673
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
|
|
2665
2674
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
2666
2675
|
}
|
|
2667
2676
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
@@ -2803,7 +2812,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2803
2812
|
switch (op) {
|
|
2804
2813
|
case "hub_delivery_retry": {
|
|
2805
2814
|
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
|
|
2806
|
-
if (
|
|
2815
|
+
if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
2807
2816
|
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
2808
2817
|
bus.retryDelivery(by, a.delivery_id);
|
|
2809
2818
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
@@ -3008,7 +3017,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3008
3017
|
if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
3009
3018
|
if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
3010
3019
|
const codex = bus.peers.get(turn.peer);
|
|
3011
|
-
if (!
|
|
3020
|
+
if (!codex?.revertTurn) throw new Error("codex is not attached");
|
|
3012
3021
|
const workspace = turnLog!.workspace(turn.id);
|
|
3013
3022
|
if (workspace !== undefined) {
|
|
3014
3023
|
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
|
|
@@ -3021,7 +3030,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3021
3030
|
const held = bus.stateOf(turn.peer) !== "paused";
|
|
3022
3031
|
if (held) bus.pause(turn.peer);
|
|
3023
3032
|
try {
|
|
3024
|
-
await codex.
|
|
3033
|
+
await codex.revertTurn(turn.native);
|
|
3025
3034
|
} finally {
|
|
3026
3035
|
if (held && !manualPaused.has(turn.peer) && !budget.record(turn.peer) && !conductorHolds.has(turn.peer)) bus.resume(turn.peer);
|
|
3027
3036
|
}
|
|
@@ -3077,11 +3086,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3077
3086
|
// 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.
|
|
3078
3087
|
const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
|
|
3079
3088
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
3080
|
-
const launch =
|
|
3089
|
+
const launch = claudeLaunchProof.permissionLaunch(peer), attached = claudeLaunchProof.owner(peer);
|
|
3081
3090
|
// 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.
|
|
3082
3091
|
if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
|
|
3083
3092
|
if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
|
|
3084
|
-
if (!
|
|
3093
|
+
if (!attached || attached.state === "offline" || !claudeLaunchProof.verified(peer))
|
|
3085
3094
|
return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
|
|
3086
3095
|
return { enforced: true };
|
|
3087
3096
|
}
|
|
@@ -3104,7 +3113,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3104
3113
|
const unattendedPeers = new Set<string>();
|
|
3105
3114
|
const startingUnattended = new Map<string, boolean>();
|
|
3106
3115
|
const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
|
|
3107
|
-
const claudePermissionGenerations = new Map<string, string>();
|
|
3108
3116
|
const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
|
|
3109
3117
|
let stopping = false;
|
|
3110
3118
|
const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
|
|
@@ -3144,7 +3152,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3144
3152
|
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
3145
3153
|
*/
|
|
3146
3154
|
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
3147
|
-
if (
|
|
3155
|
+
if (definitionOf(id)?.transport !== "acp" || peer?.steer || bus.stateOf(id) !== "busy") return {};
|
|
3148
3156
|
const since = bus.oldestQueuedImportantAt(id);
|
|
3149
3157
|
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
3150
3158
|
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
@@ -3157,8 +3165,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3157
3165
|
};
|
|
3158
3166
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
3159
3167
|
const recoveryReady = () => {
|
|
3160
|
-
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (
|
|
3168
|
+
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || ("recoveryReady" in peer && peer.recoveryReady !== true))) return false;
|
|
3161
3169
|
if (checkPending.size !== 0) return false;
|
|
3170
|
+
if (tasks.autoPullsPending() !== 0) return false; // #785 P1c: an automatic backlog pull mid-admission would write after the commit's digest; its own line keeps the line above exact for scripts/seeds.json
|
|
3162
3171
|
if (!recoveryPeerSnapshot) return true;
|
|
3163
3172
|
const current = recoveryPeers();
|
|
3164
3173
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -3324,9 +3333,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3324
3333
|
};
|
|
3325
3334
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
3326
3335
|
if (readOnlyPeer(id)) return "read-only";
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3336
|
+
const channelOwner = claudeLaunchProof.owner(id);
|
|
3337
|
+
if (channelOwner && channelOwner === peer && channelOwner.state !== "offline") {
|
|
3338
|
+
const launch = claudeLaunchProof.permissionLaunch(id);
|
|
3339
|
+
if (!launch || !claudeLaunchProof.verified(id)) return "unverified";
|
|
3330
3340
|
}
|
|
3331
3341
|
const native = peer?.permissionModeState;
|
|
3332
3342
|
// An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
|
|
@@ -3335,7 +3345,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3335
3345
|
};
|
|
3336
3346
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
3337
3347
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
3338
|
-
if (note &&
|
|
3348
|
+
if (note && peer?.pullOnly === true) return { permissionNote: note };
|
|
3339
3349
|
const mode = permissionDisplay(id, peer);
|
|
3340
3350
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
3341
3351
|
};
|
|
@@ -3403,7 +3413,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3403
3413
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
3404
3414
|
codexProxyPort: opts.codexProxyPort,
|
|
3405
3415
|
peers: Object.fromEntries(
|
|
3406
|
-
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), ...(
|
|
3416
|
+
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))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { 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), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(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 ?? [] } : {}) }]; }),
|
|
3407
3417
|
),
|
|
3408
3418
|
taskWip: tasks.wipBreakdown(peerIds),
|
|
3409
3419
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -4196,12 +4206,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4196
4206
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
4197
4207
|
if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
|
|
4198
4208
|
const owner = bus.peers.get(peer);
|
|
4199
|
-
|
|
4209
|
+
if (owner && "form" in owner && owner.form === undefined) return { ok: false, error: "peer form is unknown; refresh ahub status before stopping it" };
|
|
4210
|
+
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner?.form ?? (owner ? "headless" : startMode(peer)), owner);
|
|
4200
4211
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
4201
4212
|
if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
|
|
4202
|
-
if (!
|
|
4203
|
-
if (
|
|
4204
|
-
if (owner.state === "offline" &&
|
|
4213
|
+
if (!owner || owner.hubOwned !== true) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
4214
|
+
if (owner.form === "tui") return { ok: false, error: `${definitionOf(peer)?.displayName ?? peer} belongs to a native TUI terminal; end it in its terminal` };
|
|
4215
|
+
if (owner.state === "offline" && owner.pendingOwnedProcess !== true) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
4205
4216
|
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" };
|
|
4206
4217
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
4207
4218
|
try {
|
|
@@ -4210,7 +4221,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4210
4221
|
operational?.current();
|
|
4211
4222
|
const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
|
|
4212
4223
|
// Pi persists a session only after its first message: take live empty-session proof before stopping.
|
|
4213
|
-
if (
|
|
4224
|
+
if (owner.captureResume && owner.state === "idle") {
|
|
4214
4225
|
try { await owner.captureResume(); }
|
|
4215
4226
|
catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
|
|
4216
4227
|
}
|
|
@@ -4270,7 +4281,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4270
4281
|
return { ok: false, error: "configured ACP session selector is invalid" };
|
|
4271
4282
|
}
|
|
4272
4283
|
const retainedOwner = bus.peers.get(peer);
|
|
4273
|
-
if (adapter?.permission.genericAcp &&
|
|
4284
|
+
if (adapter?.permission.genericAcp && retainedOwner?.state === "offline" && "pendingOwnedProcess" in retainedOwner && retainedOwner.pendingOwnedProcess !== false) return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
|
|
4274
4285
|
if (adapter?.permission.genericAcp && (args.model || args.route || args.backend || args.unattended || args.providerProfile || args.sessionFile || args.fresh)) return { ok: false, error: "configured ACP accepts no native launch overrides" };
|
|
4275
4286
|
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
4276
4287
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
@@ -4353,7 +4364,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4353
4364
|
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
4354
4365
|
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
4355
4366
|
}
|
|
4356
|
-
|
|
4367
|
+
const proxy = frontendProxyState(peer, existing);
|
|
4368
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
4369
|
+
return { ok: true, already: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
|
|
4357
4370
|
}
|
|
4358
4371
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
4359
4372
|
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
@@ -4496,7 +4509,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4496
4509
|
mcpServers,
|
|
4497
4510
|
preamble: sessionRoleContract(peer),
|
|
4498
4511
|
} } });
|
|
4499
|
-
if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
|
|
4512
|
+
if (!isAcpPeer(owner) || owner.id !== peer || owner.deliveryOnly !== true || owner.hubOwned !== true || owner.form !== "headless") throw new Error("ACP factory did not return its bound native transport");
|
|
4500
4513
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4501
4514
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
4502
4515
|
catch (error) {
|
|
@@ -4585,7 +4598,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4585
4598
|
await codex.start();
|
|
4586
4599
|
await reconcileNativeStart(peer, codex);
|
|
4587
4600
|
} catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
4588
|
-
|
|
4601
|
+
const proxy = frontendProxyState(peer, codex);
|
|
4602
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
4603
|
+
return { ok: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
|
|
4589
4604
|
}
|
|
4590
4605
|
if (adapter?.start.daemonPort === "pi-rpc") {
|
|
4591
4606
|
if (!onRoster(peer)) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
|
|
@@ -5711,13 +5726,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5711
5726
|
return undefined;
|
|
5712
5727
|
}
|
|
5713
5728
|
function codexResumeCommand(peer: string): string {
|
|
5714
|
-
const owner = bus.peers.get(peer), thread =
|
|
5729
|
+
const owner = bus.peers.get(peer), thread = owner?.nativeSession ?? lastCodexThreads.get(peer);
|
|
5715
5730
|
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
5716
5731
|
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
5717
5732
|
}
|
|
5718
5733
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
5719
5734
|
const owner = bus.peers.get(peer);
|
|
5720
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer,
|
|
5735
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner?.form, codexResumeCommand(peer));
|
|
5721
5736
|
}
|
|
5722
5737
|
|
|
5723
5738
|
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: SettingSource, confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
|
|
@@ -5749,9 +5764,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5749
5764
|
if (!after) {
|
|
5750
5765
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
5751
5766
|
const owner = bus.peers.get(peer);
|
|
5752
|
-
if (
|
|
5753
|
-
|
|
5754
|
-
|
|
5767
|
+
if (owner?.detachForRoster && owner.form !== "headless") {
|
|
5768
|
+
await owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
|
|
5769
|
+
if (definitionOf(peer)?.attach?.admission) clearKimiAdmission();
|
|
5770
|
+
}
|
|
5755
5771
|
else if (owner && owner.state !== "offline") {
|
|
5756
5772
|
const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
|
|
5757
5773
|
if (result.ok !== true) throw new Error(`roster saved; ${peer} stop is unconfirmed; permission reset and task handoff were not attempted; inspect ahub status and reapply ${def.key} false to retry`);
|
|
@@ -5766,7 +5782,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5766
5782
|
handoff = await tasks.reassignForRoster(peer, handoffCurrent);
|
|
5767
5783
|
} else {
|
|
5768
5784
|
const owner = bus.peers.get(peer);
|
|
5769
|
-
if (
|
|
5785
|
+
if (owner?.form !== "headless") await owner?.resumeFromRoster?.();
|
|
5770
5786
|
}
|
|
5771
5787
|
checkOperational(authority);
|
|
5772
5788
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
@@ -6080,10 +6096,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6080
6096
|
// #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
|
|
6081
6097
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
6082
6098
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
6099
|
+
...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
|
|
6083
6100
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
6084
6101
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
6085
6102
|
...(releasing ? ["ready tasks being released"] : []),
|
|
6086
|
-
...([...bus.peers].filter(([, peer]) =>
|
|
6103
|
+
...([...bus.peers].filter(([, peer]) => peer.state !== "busy" && "recoveryReady" in peer && peer.recoveryReady !== true).map(([id, peer]) => !membershipAllowed(id) && peer.form === "tui" ? `${id} is not settled; end the retained ${definitionOf(id)?.displayName ?? id} TUI, then prepare again` : `${id} is not settled`)),
|
|
6087
6104
|
],
|
|
6088
6105
|
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
6089
6106
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
@@ -6120,8 +6137,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6120
6137
|
}
|
|
6121
6138
|
|
|
6122
6139
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
6123
|
-
for (const
|
|
6124
|
-
try { await
|
|
6140
|
+
for (const peer of bus.peers.values()) if (peer.captureResume && peer.state === "idle") {
|
|
6141
|
+
try { await peer.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
6125
6142
|
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
6126
6143
|
}
|
|
6127
6144
|
}
|
|
@@ -6230,23 +6247,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6230
6247
|
return recoveryError("unknown recovery operation");
|
|
6231
6248
|
}
|
|
6232
6249
|
|
|
6233
|
-
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
6234
|
-
if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
|
|
6235
|
-
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6236
|
-
return row?.permissionHook ? row : undefined;
|
|
6237
|
-
}
|
|
6238
6250
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
6239
6251
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
6240
6252
|
if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
6241
6253
|
if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
|
|
6242
|
-
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
|
|
6243
|
-
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
6244
6254
|
const owner = bus.peers.get(peer);
|
|
6255
|
+
const unmanagedForm = owner ? owner.form === "tui" || "form" in owner && owner.form === undefined : startMode(peer) === "tui";
|
|
6256
|
+
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && unmanagedForm) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
|
|
6257
|
+
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
6258
|
+
const proxy = frontendProxyState(peer, owner);
|
|
6259
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
6245
6260
|
if (mode === "ask" && (!owner || owner.state === "offline")) {
|
|
6246
6261
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
6247
6262
|
// to it would run a mode the status no longer shows.
|
|
6248
6263
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
6249
|
-
const note =
|
|
6264
|
+
const note = proxy.port && !proxy.port.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
|
|
6250
6265
|
if (note) log(`permission mode codex: ${note}`);
|
|
6251
6266
|
const from = permissionMode(peer);
|
|
6252
6267
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -6254,17 +6269,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6254
6269
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
6255
6270
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
6256
6271
|
}
|
|
6257
|
-
if (
|
|
6272
|
+
if (proxy.port && !proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
6258
6273
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
6259
6274
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
6260
6275
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
6261
6276
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
6262
6277
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
6263
|
-
const launch =
|
|
6264
|
-
if (!launch ||
|
|
6278
|
+
const launch = claudeLaunchProof.permissionLaunch(peer);
|
|
6279
|
+
if (!launch || claudeLaunchProof.owner(peer) !== owner || !claudeLaunchProof.verified(peer)) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
6265
6280
|
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" };
|
|
6266
|
-
} else if (
|
|
6267
|
-
if (!
|
|
6281
|
+
} else if (proxy.port) {
|
|
6282
|
+
if (!proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
6268
6283
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
6269
6284
|
}
|
|
6270
6285
|
const from = permissionMode(peer);
|
|
@@ -6294,8 +6309,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6294
6309
|
writeStatus(); return { ok: true, peer, permissionMode: permissionMode(peer) };
|
|
6295
6310
|
}
|
|
6296
6311
|
const owner = bus.peers.get(peer);
|
|
6312
|
+
const proxy = frontendProxyState(peer, owner);
|
|
6313
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
6297
6314
|
if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
|
|
6298
|
-
if (!starting.has(peer) &&
|
|
6315
|
+
if (!starting.has(peer) && proxy.port && owner?.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
6299
6316
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
6300
6317
|
const result = await changePermission(peer, "never-ask", true);
|
|
6301
6318
|
if (!result.ok) return result;
|
|
@@ -6326,9 +6343,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6326
6343
|
const result = await permissionChange(peer, async () => {
|
|
6327
6344
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
6328
6345
|
const selected = permissionMode(peer);
|
|
6329
|
-
|
|
6330
|
-
if (
|
|
6331
|
-
|
|
6346
|
+
const proxy = frontendProxyState(peer, owner);
|
|
6347
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
6348
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
|
|
6349
|
+
if (!readOnlyPeer(peer) && owner.permissionModeState !== "unmanaged" && owner.getPermissionMode() !== selected) {
|
|
6350
|
+
if (proxy.port && !proxy.port.attached) proxy.port.setStartupPermissionMode(selected);
|
|
6332
6351
|
else await owner.setPermissionMode(selected);
|
|
6333
6352
|
}
|
|
6334
6353
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -6397,12 +6416,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6397
6416
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
6398
6417
|
if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
|
|
6399
6418
|
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
6400
|
-
|
|
6401
|
-
try {
|
|
6402
|
-
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
6403
|
-
if (launch) currentLaunch = launch.launchId;
|
|
6404
|
-
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
6405
|
-
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
6419
|
+
if (claudeLaunchProof.helloLaunch(c.peer, msg.launchId) === "refuse") return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
6406
6420
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
6407
6421
|
}
|
|
6408
6422
|
let peer = bus.peers.get(c.peer);
|
|
@@ -6777,15 +6791,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6777
6791
|
}
|
|
6778
6792
|
let nativeClaude = false;
|
|
6779
6793
|
if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
|
|
6780
|
-
|
|
6781
|
-
try {
|
|
6782
|
-
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
6783
|
-
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;
|
|
6784
|
-
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
6785
|
-
try {
|
|
6786
|
-
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6787
|
-
if (launch) expectedLaunch = launch.launchId;
|
|
6788
|
-
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
6794
|
+
const expectedLaunch = claudeLaunchProof.expectedLaunch(peer);
|
|
6789
6795
|
const current = claudeSession(peer);
|
|
6790
6796
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
6791
6797
|
if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
|
|
@@ -6799,12 +6805,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6799
6805
|
if (!nativeClaude) return void reply({ t: "facts", ok: false });
|
|
6800
6806
|
}
|
|
6801
6807
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
6802
|
-
const nativePeer = nativeClaude ?
|
|
6803
|
-
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ?
|
|
6808
|
+
const nativePeer = nativeClaude ? claudeLaunchProof.owner(peer) : undefined;
|
|
6809
|
+
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudeLaunchProof.permissionLaunch(peer) : undefined;
|
|
6804
6810
|
const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
|
|
6805
6811
|
if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
|
|
6806
6812
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
|
|
6807
|
-
if (permission !== undefined &&
|
|
6813
|
+
if (permission !== undefined && nativePeer) claudeLaunchProof.bindGeneration(peer, nativePeer.sessionGeneration);
|
|
6808
6814
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
6809
6815
|
// 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`.
|
|
6810
6816
|
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
|
|
@@ -6814,7 +6820,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6814
6820
|
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")));
|
|
6815
6821
|
if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
6816
6822
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
6817
|
-
if ((phase === "start" || phase === "pre") &&
|
|
6823
|
+
if ((phase === "start" || phase === "pre") && nativePeer) {
|
|
6818
6824
|
const previous = nativeTurnStart.get(peer);
|
|
6819
6825
|
const current = claudeSession(peer);
|
|
6820
6826
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
@@ -6837,7 +6843,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6837
6843
|
const st = hookStats.get(peer)!;
|
|
6838
6844
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
6839
6845
|
hookStats.delete(peer);
|
|
6840
|
-
if (id &&
|
|
6846
|
+
if (id && nativePeer) nativePeer.nativeStopped(sessionId!);
|
|
6841
6847
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
6842
6848
|
};
|
|
6843
6849
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
@@ -6846,13 +6852,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6846
6852
|
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
6847
6853
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
6848
6854
|
const started = nativeTurnStart.get(peer);
|
|
6849
|
-
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") &&
|
|
6855
|
+
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && !!nativePeer && started.generation === nativePeer.sessionGeneration;
|
|
6850
6856
|
// A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
|
|
6851
6857
|
if (!activeStart) {
|
|
6852
6858
|
const latest = readCompletion();
|
|
6853
6859
|
if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
|
|
6854
6860
|
}
|
|
6855
|
-
if (!started || !activeStart || !
|
|
6861
|
+
if (!started || !activeStart || !nativePeer) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
6856
6862
|
const scopeCurrent = () => {
|
|
6857
6863
|
const now = claudeSession(peer);
|
|
6858
6864
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
@@ -7349,6 +7355,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7349
7355
|
dashboard?.stop();
|
|
7350
7356
|
stopConfigWatch();
|
|
7351
7357
|
clearInterval(sweepTimer);
|
|
7358
|
+
tasks.stopCiWatchers(); // #785 P1b: no per-head CI watcher outlives the hub
|
|
7352
7359
|
for (const i of intervals) clearInterval(i);
|
|
7353
7360
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
7354
7361
|
for (const p of permissions.values()) p.done(undefined);
|
package/src/hub/peers.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
2
2
|
import type { PermissionMode } from "./permission-mode.ts";
|
|
3
|
+
import type { StartForm } from "../adapters/registry-types.ts";
|
|
3
4
|
|
|
4
5
|
export interface DeliveryReceipt {
|
|
5
6
|
id: string;
|
|
@@ -14,6 +15,15 @@ export interface OwnedToolsLaunch {
|
|
|
14
15
|
launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
export interface FrontendProxyPort {
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly attached: boolean;
|
|
21
|
+
/** Only before a frontend attaches, while offline and with no native restoration debt. */
|
|
22
|
+
setStartupPermissionMode(mode: PermissionMode): void;
|
|
23
|
+
/** Clear to ask while retaining any native restoration debt; false means its baseline is unknown. */
|
|
24
|
+
clearPermissionMode(): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export interface PeerAdapter {
|
|
18
28
|
readonly id: PeerId;
|
|
19
29
|
ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
|
|
@@ -27,6 +37,30 @@ export interface PeerAdapter {
|
|
|
27
37
|
readonly permissionModeState?: PermissionMode | "unknown" | "unmanaged";
|
|
28
38
|
/** Resolve only after native acknowledgement. This changes approval frequency, never the sandbox. */
|
|
29
39
|
setPermissionMode?(mode: PermissionMode): Promise<void>;
|
|
40
|
+
/** Never offered delivery retry; absent/false permits retry, present undefined refuses it. */
|
|
41
|
+
readonly deliveryOnly?: boolean;
|
|
42
|
+
/** The hub owns this runtime and may stop it; absent or undefined refuses. */
|
|
43
|
+
readonly hubOwned?: boolean;
|
|
44
|
+
/** An owned process remains whose exit is unconfirmed. */
|
|
45
|
+
readonly pendingOwnedProcess?: boolean;
|
|
46
|
+
/** Native running form; absent keeps the site's default, present undefined is unknown. */
|
|
47
|
+
readonly form?: StartForm;
|
|
48
|
+
/** Absent is settled; false or a present undefined value refuses recovery readiness. */
|
|
49
|
+
readonly recoveryReady?: boolean;
|
|
50
|
+
/** Capture live resume evidence before a requested stop; throws when unavailable. */
|
|
51
|
+
captureResume?(): Promise<Record<string, unknown>>;
|
|
52
|
+
/** Native session binding; absence or undefined is unbound, never inferred from a different reader. */
|
|
53
|
+
readonly nativeSession?: string;
|
|
54
|
+
/** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
|
|
55
|
+
readonly frontendProxy?: FrontendProxyPort;
|
|
56
|
+
/** Fixed operator notices from builtin native adapters, never provider text or native ids. */
|
|
57
|
+
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
|
|
58
|
+
/** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
|
|
59
|
+
revertTurn?(nativeTurn: string): Promise<void>;
|
|
60
|
+
/** Leave the roster without stopping a person's terminal. */
|
|
61
|
+
detachForRoster?(resetMode: PermissionMode): void | Promise<void>;
|
|
62
|
+
/** Return to the roster after detachForRoster. */
|
|
63
|
+
resumeFromRoster?(): Promise<void>;
|
|
30
64
|
/** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
|
|
31
65
|
readonly hubNative?: boolean;
|
|
32
66
|
/** Never handed a delivery: its queue waits for `Bus.pull` (a Claude session that cannot show channel pushes, issue #205). */
|
package/src/hub/settings.ts
CHANGED
|
@@ -57,6 +57,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
57
57
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
58
58
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
59
59
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
60
|
+
{ key: "tasks.auto_pull", group: "Switches", label: "Auto-pull the top reserved ready card when a planner slot frees", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["tasks", "auto_pull"] },
|
|
60
61
|
{ key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
61
62
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
62
63
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
5
5
|
import { hostExecutable } from "./host-executable.ts";
|
|
6
6
|
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
@@ -38,7 +38,8 @@ export async function readTaskShellDefaultBranch(root: string, env: Record<strin
|
|
|
38
38
|
if (!held.isDirectory() || realPath(own) !== own || JSON.stringify([held.dev, held.ino, held.mode]) !== identity) throw new Error("unavailable");
|
|
39
39
|
rmSync(own, { recursive: true, force: true }); temporary = undefined;
|
|
40
40
|
};
|
|
41
|
-
|
|
41
|
+
// Git stops before inspecting a ceiling ancestor; naming cwd itself still permits discovery in its parent.
|
|
42
|
+
const environment = hubGitEnv({ PATH: env.PATH, HOME: own, XDG_CONFIG_HOME: own, LANG: env.LANG ?? "C", TERM: "dumb", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CEILING_DIRECTORIES: dirname(own) }, { repositoryIsolation: true });
|
|
42
43
|
const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
|
|
43
44
|
if (!current()) { remove(); return; }
|
|
44
45
|
return await new Promise<string | undefined>(resolve => {
|