@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +3 -2
  4. package/docs/agent-notes/bus.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -2
  6. package/docs/agent-notes/local-worker.md +1 -0
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +4 -3
  9. package/docs/dogfood.md +4 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +40 -8
  12. package/docs/quickstart.md +7 -3
  13. package/docs/sdlc.md +12 -0
  14. package/docs/security.md +4 -4
  15. package/docs/specs/2026-09-19-agent-hub-design.md +85 -3
  16. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  17. package/docs/verified.json +24 -16
  18. package/package.json +2 -2
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +103 -7
  21. package/src/adapters/acp.ts +8 -0
  22. package/src/adapters/channel-peer.ts +7 -5
  23. package/src/adapters/claude-implementer.ts +1 -0
  24. package/src/adapters/codex-appserver.ts +11 -1
  25. package/src/adapters/conformance/enrollment.ts +4 -4
  26. package/src/adapters/conformance/factories.ts +2 -1
  27. package/src/adapters/conformance/kit.ts +10 -0
  28. package/src/adapters/local-worker.ts +2 -0
  29. package/src/adapters/pi.ts +7 -0
  30. package/src/adapters/reviewer-print.ts +1 -0
  31. package/src/hub/ci-watch.ts +140 -0
  32. package/src/hub/claude-launch-proof.ts +67 -2
  33. package/src/hub/config-apply.ts +2 -2
  34. package/src/hub/config-trust.ts +1 -0
  35. package/src/hub/daemon.ts +86 -79
  36. package/src/hub/peers.ts +34 -0
  37. package/src/hub/settings.ts +1 -0
  38. package/src/hub/task-shell-default.ts +3 -2
  39. package/src/hub/tasks.ts +139 -7
  40. package/src/local/deny.ts +21 -0
  41. 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 isWsPeer(peer) && peer.pullOnly; },
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
- if (isCodexPeer(p)) return p.thread || undefined;
2015
- if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
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(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
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 (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
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 (!isCodexPeer(codex)) throw new Error("codex is not attached");
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.revert(turn.native);
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 = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
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 (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
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 (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
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" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
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
- if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
3328
- const launch = claudePermissionLaunch(id);
3329
- if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
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 && isWsPeer(peer) && peer.pullOnly) return { permissionNote: 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), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(p)) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(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 ?? [] } : {}) }]; }),
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
- const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
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 (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
4203
- if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
4204
- if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
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 (isPiPeer(owner) && owner.state === "idle") {
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 && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
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
- return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
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
- return { ok: true, proxyUrl: codex.proxyUrl };
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 = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
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, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(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 (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
5753
- else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5754
- else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
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 (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
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]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
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 pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
6124
- try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
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 = isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.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;
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 (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
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 = claudePermissionLaunch(peer);
6264
- if (!launch || !isWsPeer(owner) || 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" };
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 (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
6267
- if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
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) && isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
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
- if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && 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" };
6330
- if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
6331
- if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
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
- let currentLaunch: unknown;
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
- let expectedLaunch: string | undefined;
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 ? bus.peers.get(peer) : undefined;
6803
- const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
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 && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
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") && isWsPeer(nativePeer)) {
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 && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
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 ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
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 || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
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). */
@@ -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
- 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: own }, { repositoryIsolation: true });
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 => {