@staix/agent-hub 0.16.0 → 0.17.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 +18 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +18 -5
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/tasks.md +2 -2
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +6 -3
- package/docs/operations.md +45 -18
- package/docs/quickstart.md +6 -2
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +15 -5
- package/docs/smoke.md +51 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +40 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +20 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1022 -160
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +7 -2
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +5 -1
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/daemon.ts +134 -22
- package/src/hub/memory-plan-guard.ts +8 -33
- package/src/hub/tasks.ts +25 -12
- package/src/memory/brief.ts +45 -5
package/src/hub/daemon.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } fro
|
|
|
46
46
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
47
47
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
48
48
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
49
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
49
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
|
|
50
50
|
import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
51
51
|
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
52
52
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
@@ -79,6 +79,7 @@ import type { ServerWebSocket } from "bun";
|
|
|
79
79
|
import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
|
|
80
80
|
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
81
81
|
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
82
|
+
import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
|
|
82
83
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
83
84
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
84
85
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
@@ -89,8 +90,8 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
89
90
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
90
91
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
91
92
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
92
|
-
import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
93
|
-
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
93
|
+
import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
94
|
+
import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
|
|
94
95
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
95
96
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
96
97
|
import { Capture } from "../memory/capture.ts";
|
|
@@ -152,7 +153,7 @@ import { detectTooling } from "./tooling.ts";
|
|
|
152
153
|
import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
153
154
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
154
155
|
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
155
|
-
import { syncMemory } from "@staix/agent-mem";
|
|
156
|
+
import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
|
|
156
157
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
157
158
|
import { nativeRecall } from "../memory/recall.ts";
|
|
158
159
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
@@ -593,6 +594,9 @@ export function resolutionRefusal(error: unknown): string {
|
|
|
593
594
|
return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
/** No remote set and a stop are not failures: agent-mem words the missing remote "no sync remote" (v1) or "no generation sync remote configured". */
|
|
598
|
+
export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
|
|
599
|
+
|
|
596
600
|
export async function startDaemon(opts: DaemonOptions) {
|
|
597
601
|
const projectId = opts.projectId ?? randomUUID();
|
|
598
602
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
@@ -630,6 +634,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
630
634
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
631
635
|
const verifiedTerminalRootsThisTurn = new Set<object>();
|
|
632
636
|
const reviewerUnavailable = new Map<string, string>();
|
|
637
|
+
const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
|
|
633
638
|
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
634
639
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
635
640
|
const startModesFromInstances: PeerStartConfig = {};
|
|
@@ -1197,6 +1202,47 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1197
1202
|
}
|
|
1198
1203
|
async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
|
|
1199
1204
|
|
|
1205
|
+
async function taskIntentBrief(task: import("../memory/brief.ts").BriefTask): Promise<import("../memory/brief.ts").IntentBriefContext> {
|
|
1206
|
+
checkMemory();
|
|
1207
|
+
const initial = task.id === undefined ? undefined : board.get(task.id), revision = configRevision;
|
|
1208
|
+
const allowed = () => {
|
|
1209
|
+
const current = task.id === undefined ? undefined : board.get(task.id);
|
|
1210
|
+
return !stopping && configRevision === revision && !!initial && !!current && current.created === task.created && current.updated === initial.updated
|
|
1211
|
+
&& current.history.length === initial.history.length && current.owner === initial.owner && !tasks.isPii(current)
|
|
1212
|
+
&& !turns.get(current.owner ?? "")?.private && !contextPii(current.owner ?? "");
|
|
1213
|
+
};
|
|
1214
|
+
const assertCurrent = () => { checkMemory(); if (!allowed()) throw Error("memory brief context changed"); };
|
|
1215
|
+
assertCurrent();
|
|
1216
|
+
// Derived code neighbours are additional read-time selectors, never persisted memory or authority.
|
|
1217
|
+
const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
|
|
1218
|
+
let extra: string[] = [];
|
|
1219
|
+
if (starts.length && tasks.memoryCodeEligible(initial!)) {
|
|
1220
|
+
try {
|
|
1221
|
+
const prepared = await memoryCode(starts, allowed);
|
|
1222
|
+
assertCurrent();
|
|
1223
|
+
extra = currentMemoryCode(prepared).nodes.map(node => node.id).filter(node => node.startsWith("symbol:")).map(node => node.slice(7));
|
|
1224
|
+
} catch { /* Optional code evidence never removes direct intent memory. */ }
|
|
1225
|
+
}
|
|
1226
|
+
assertCurrent();
|
|
1227
|
+
const stale = staleness();
|
|
1228
|
+
const policy = createStandalonePolicy({ denyPaths: config.local.deny, host: { screen: async value => {
|
|
1229
|
+
assertCurrent();
|
|
1230
|
+
if (value && typeof value === "object" && "id" in value) {
|
|
1231
|
+
const row = value as MemoryView;
|
|
1232
|
+
return screenMemory([row]).length === 1 && tasks.publicMetadataText(JSON.stringify(row));
|
|
1233
|
+
}
|
|
1234
|
+
// The trusted host locator is not note content (a private directory can contain this project).
|
|
1235
|
+
const { cwd: _cwd, ...query } = value as Record<string, unknown>;
|
|
1236
|
+
return boundedSafe(JSON.stringify(query));
|
|
1237
|
+
} } });
|
|
1238
|
+
return { source: createIntentBriefSource(memoryStore!, memoryProject), options: {
|
|
1239
|
+
project: memoryProject, cwd: opts.cwd, repo: memoryRepo, intent: task.title,
|
|
1240
|
+
paths: [...task.refs.paths ?? [], ...task.plan?.paths ?? []], symbols: [...task.plan?.symbols ?? [], ...extra],
|
|
1241
|
+
policy: () => policy, policyVersion: () => String(configRevision), allowed, assertCurrent,
|
|
1242
|
+
stale: row => stale(row.about), now: () => memoryStore!.readNow(),
|
|
1243
|
+
} };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1200
1246
|
const leaseNoticed = new Set<string>();
|
|
1201
1247
|
const goalLine = (g: GoalView) => `goal:${g.id.slice(0, 8)} ${g.state} ${g.title}${g.lease.holder ? ` (held by ${g.lease.holder === memoryStore?.machine.id ? "this machine" : `machine ${g.lease.holder.slice(0, 8)}`} until ${g.lease.until}${g.lease.conflict.length ? `; conflicting claim from ${g.lease.conflict.map((m) => m.slice(0, 8)).join(", ")}` : ""})` : ""}`;
|
|
1202
1248
|
/**
|
|
@@ -1256,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1256
1302
|
try {
|
|
1257
1303
|
checkMemory();
|
|
1258
1304
|
const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
|
|
1259
|
-
if (!r.ok &&
|
|
1305
|
+
if (!r.ok && !quietSyncError(r.error)) log(`hub memory sync failed: ${r.error}`);
|
|
1260
1306
|
else if (r.warning) log(`hub memory sync: ${r.warning}`);
|
|
1261
1307
|
} catch { /* A stale binding disables native memory, never the bus. */ }
|
|
1262
1308
|
finally { syncRun = undefined; }
|
|
@@ -1497,7 +1543,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1497
1543
|
};
|
|
1498
1544
|
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1499
1545
|
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1500
|
-
|
|
1546
|
+
// #792: only a reviewer backend runs bound reviews; an unavailable placeholder slot (agy) fences nothing.
|
|
1547
|
+
if (!definitionOf(by)?.reviewer?.backend) return undefined;
|
|
1501
1548
|
const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
|
|
1502
1549
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1503
1550
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
@@ -1584,7 +1631,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1584
1631
|
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1585
1632
|
memoryStore ? taskMemoryItems : undefined,
|
|
1586
1633
|
(peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
|
|
1587
|
-
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }
|
|
1634
|
+
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
|
|
1635
|
+
memoryStore ? taskIntentBrief : undefined) } : {}),
|
|
1588
1636
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
1589
1637
|
checkMemory();
|
|
1590
1638
|
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
|
|
@@ -2141,7 +2189,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2141
2189
|
const bound = executionOwners.get(peer);
|
|
2142
2190
|
if (bound?.socket) {
|
|
2143
2191
|
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2144
|
-
if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2192
|
+
if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2145
2193
|
revokeTerminalExecution(peer);
|
|
2146
2194
|
return undefined;
|
|
2147
2195
|
}
|
|
@@ -2423,6 +2471,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2423
2471
|
if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
|
|
2424
2472
|
const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
|
|
2425
2473
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
2474
|
+
const reviewTool = op === "hub_review" || op === "hub_task_show" ? op : undefined;
|
|
2475
|
+
const traceId = admittedId ?? (typeof input?.id === "string" && /^[1-9][0-9]{0,15}$/.test(input.id) && Number.isSafeInteger(Number(input.id)) ? Number(input.id) : undefined);
|
|
2476
|
+
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool);
|
|
2426
2477
|
taskOpsInFlight++;
|
|
2427
2478
|
try {
|
|
2428
2479
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
@@ -2438,6 +2489,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2438
2489
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2439
2490
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2440
2491
|
} catch (error) {
|
|
2492
|
+
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
|
|
2441
2493
|
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2442
2494
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2443
2495
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
@@ -2793,7 +2845,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2793
2845
|
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
2794
2846
|
const turn = turnLog?.get(String(a.turn));
|
|
2795
2847
|
if (!turn) throw new Error(`no turn ${a.turn} recorded`);
|
|
2796
|
-
if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2848
|
+
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`);
|
|
2797
2849
|
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`);
|
|
2798
2850
|
const codex = bus.peers.get(turn.peer);
|
|
2799
2851
|
if (!isCodexPeer(codex)) throw new Error("codex is not attached");
|
|
@@ -3190,7 +3242,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3190
3242
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
3191
3243
|
codexProxyPort: opts.codexProxyPort,
|
|
3192
3244
|
peers: Object.fromEntries(
|
|
3193
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
3245
|
+
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), ...(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 ?? [] } : {}) }]; }),
|
|
3194
3246
|
),
|
|
3195
3247
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
3196
3248
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -3265,7 +3317,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3265
3317
|
settlingSuperseded = true;
|
|
3266
3318
|
try {
|
|
3267
3319
|
for (const row of bus.queueList(undefined, true)) {
|
|
3268
|
-
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
3320
|
+
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:") || row.reason?.startsWith(REVIEWER_FALLBACK_UNCONFIRMED)) continue;
|
|
3269
3321
|
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
3270
3322
|
if (!reason) continue;
|
|
3271
3323
|
try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
|
|
@@ -3984,6 +4036,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3984
4036
|
const owner = bus.peers.get(peer);
|
|
3985
4037
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3986
4038
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
4039
|
+
if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
|
|
3987
4040
|
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" };
|
|
3988
4041
|
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3989
4042
|
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
@@ -4083,6 +4136,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4083
4136
|
if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
|
|
4084
4137
|
}
|
|
4085
4138
|
const existing = bus.peers.get(peer);
|
|
4139
|
+
if (adapter?.start.createTui && existing) {
|
|
4140
|
+
// One Qwen at a time, in one form: a headless worker and the person's TUI would both read the same session's work. The TUI is judged by the process it holds, not by peer
|
|
4141
|
+
// state: a detached TUI reads offline while the person's Qwen keeps running, and replacing that adapter would strand it and admit a second Qwen beside it.
|
|
4142
|
+
if (isQwenDualPeer(existing) && existing.holdsTerminal()) return args.mode === "tui" ? { ok: true, already: true } : { ok: false, error: "Qwen runs in its own terminal; end it there before starting a headless one" };
|
|
4143
|
+
if (args.mode === "tui" && !isQwenDualPeer(existing) && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
|
|
4144
|
+
}
|
|
4086
4145
|
if (args.replaceSession) {
|
|
4087
4146
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
4088
4147
|
if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
|
|
@@ -4130,12 +4189,49 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4130
4189
|
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
4131
4190
|
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
4132
4191
|
}
|
|
4133
|
-
return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
4192
|
+
return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
4134
4193
|
}
|
|
4135
4194
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
4136
4195
|
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
4137
4196
|
else await existing?.stop();
|
|
4138
4197
|
}
|
|
4198
|
+
if (adapter?.start.createTui && args.mode === "tui") {
|
|
4199
|
+
// #286 Part 3: Qwen's own TUI, driven through its dual-output files. The person's terminal runs it; the hub prepares the files and returns the launch.
|
|
4200
|
+
if (declarations[peer]) return { ok: false, error: "declared instances support headless starts only" };
|
|
4201
|
+
if (!onRoster(peer)) return { ok: false, error: "qwen is off roster; a person can add it with ahub roster add qwen" };
|
|
4202
|
+
if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
|
|
4203
|
+
// The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
|
|
4204
|
+
const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
|
|
4205
|
+
if (!verdict.ok) return { ok: false, error: verdict.reason };
|
|
4206
|
+
if (verdict.note) log(verdict.note);
|
|
4207
|
+
if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
|
|
4208
|
+
actionBudgets.revoke(peer);
|
|
4209
|
+
const nativeRoot = nativeStateDir(peer);
|
|
4210
|
+
mkdirSync(nativeRoot, { recursive: true });
|
|
4211
|
+
const qwenState = join(nativeRoot, "qwen");
|
|
4212
|
+
const tui: QwenDualPeer = adapter.start.createTui({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["qwen-dual"]({
|
|
4213
|
+
cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
4214
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
|
|
4215
|
+
watchdogMs: config.watchdog_ms, preamble: sessionRoleContract(peer),
|
|
4216
|
+
// The hub's own tools only, by their exact `mcp__agent-hub__<name>`; everything else asks the console. Never a prefix, never a title.
|
|
4217
|
+
autoApprove: name => !stopping && HUB_TOOL_TITLES.has(name),
|
|
4218
|
+
// `stateDir` is the hub's own state dir: the adapter refuses any Qwen write or command that names it (the input file is a prompt channel).
|
|
4219
|
+
guardDir: opts.stateDir,
|
|
4220
|
+
onPermission, actionGate: (call, session) => acpActionGate(peer)({ names: call.names, input: call.input }, session),
|
|
4221
|
+
onStalled: () => writeStatus(),
|
|
4222
|
+
onNotice: text => { try { notify(text); } catch { /* the notice is advisory */ } },
|
|
4223
|
+
onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
|
|
4224
|
+
log,
|
|
4225
|
+
}) }) as QwenDualPeer;
|
|
4226
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4227
|
+
executionOwners.set(peer, { owner: tui, cwd: executionCwd });
|
|
4228
|
+
try {
|
|
4229
|
+
await tui.start();
|
|
4230
|
+
bus.add(tui);
|
|
4231
|
+
if (tui.state !== "offline") tui.onState?.(tui.state);
|
|
4232
|
+
} catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
4233
|
+
return { ok: true, mode: "tui", ...(tui.adopted ? { adopted: true } : { launch: tui.launch }) };
|
|
4234
|
+
}
|
|
4139
4235
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
4140
4236
|
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
|
|
4141
4237
|
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
@@ -4638,7 +4734,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4638
4734
|
privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
|
|
4639
4735
|
bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
|
|
4640
4736
|
taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
|
|
4641
|
-
call: (name, args) => taskOp(peer, name, args, true, contextPii(peer))
|
|
4737
|
+
call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)),
|
|
4738
|
+
onTool: (tool, refusal) => { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); } }),
|
|
4642
4739
|
admit: env => {
|
|
4643
4740
|
if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
|
|
4644
4741
|
{ head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
|
|
@@ -4652,16 +4749,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4652
4749
|
facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
|
|
4653
4750
|
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
4654
4751
|
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
4752
|
+
onNoVerdict: (diagnostic, current) => {
|
|
4753
|
+
const task = board.get(diagnostic.task);
|
|
4754
|
+
const visible = current() && bus.peers.get(peer) === reviewer && !!task && !tasks.isPii(task) && !contextPii(peer)
|
|
4755
|
+
&& tasks.reviewBindingCurrent(peer, diagnostic.task, diagnostic);
|
|
4756
|
+
// Screen the complete refusal before any sanitization or bound; no task text or model output is logged.
|
|
4757
|
+
const hostSafe = visible && !diagnostic.refusalUncertain && (diagnostic.hostRefusal === undefined || boundedSafe(diagnostic.hostRefusal) && tasks.publicMetadataText(diagnostic.hostRefusal));
|
|
4758
|
+
const safe = visible && hostSafe && diagnostic.refusal !== undefined && boundedSafe(diagnostic.refusal) && tasks.publicMetadataText(diagnostic.refusal) ? diagnostic.refusal : undefined;
|
|
4759
|
+
log(reviewerNoVerdictLine(peer, diagnostic, safe, !visible));
|
|
4760
|
+
},
|
|
4655
4761
|
onUnavailable: (failure, current) => {
|
|
4656
4762
|
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
4657
|
-
if (!attached()) return;
|
|
4763
|
+
if (!attached()) return false;
|
|
4658
4764
|
if (failure.cause === "start_failed") {
|
|
4659
4765
|
// #722: the class (a closed list) is the only detail; never a path, native text or token.
|
|
4660
4766
|
const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
|
|
4661
4767
|
log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
|
|
4662
4768
|
reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
|
|
4663
4769
|
}
|
|
4664
|
-
|
|
4770
|
+
let confirmed = false;
|
|
4771
|
+
return tasks.retryReviewer(failure.task, peer, failure, attached, () => { confirmed = true; }).then(() => confirmed);
|
|
4665
4772
|
},
|
|
4666
4773
|
log,
|
|
4667
4774
|
}) }) as ReviewerPrintPeer;
|
|
@@ -4677,6 +4784,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4677
4784
|
}
|
|
4678
4785
|
reviewerUnavailable.delete(peer);
|
|
4679
4786
|
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
4787
|
+
reviewerToolObservers.set(peer, (task, tool, refusal) => {
|
|
4788
|
+
try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
|
|
4789
|
+
catch { /* Diagnostics never change a host tool's result or authority. */ }
|
|
4790
|
+
});
|
|
4680
4791
|
bus.add(reviewer);
|
|
4681
4792
|
return { ok: true };
|
|
4682
4793
|
}
|
|
@@ -5797,6 +5908,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5797
5908
|
}
|
|
5798
5909
|
|
|
5799
5910
|
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5911
|
+
if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
|
|
5800
5912
|
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
5801
5913
|
return row?.permissionHook ? row : undefined;
|
|
5802
5914
|
}
|
|
@@ -5811,7 +5923,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5811
5923
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
5812
5924
|
// to it would run a mode the status no longer shows.
|
|
5813
5925
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
5814
|
-
const note = 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;
|
|
5926
|
+
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;
|
|
5815
5927
|
if (note) log(`permission mode codex: ${note}`);
|
|
5816
5928
|
const from = permissionMode(peer);
|
|
5817
5929
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -5819,7 +5931,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5819
5931
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
5820
5932
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
5821
5933
|
}
|
|
5822
|
-
if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5934
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5823
5935
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
5824
5936
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
5825
5937
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
@@ -5828,7 +5940,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5828
5940
|
const launch = claudePermissionLaunch(peer);
|
|
5829
5941
|
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" };
|
|
5830
5942
|
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" };
|
|
5831
|
-
} else if (isCodexPeer(owner)) {
|
|
5943
|
+
} else if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
|
|
5832
5944
|
if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5833
5945
|
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" };
|
|
5834
5946
|
}
|
|
@@ -5860,7 +5972,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5860
5972
|
}
|
|
5861
5973
|
const owner = bus.peers.get(peer);
|
|
5862
5974
|
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" };
|
|
5863
|
-
if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
5975
|
+
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" };
|
|
5864
5976
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
5865
5977
|
const result = await changePermission(peer, "never-ask", true);
|
|
5866
5978
|
if (!result.ok) return result;
|
|
@@ -5891,9 +6003,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5891
6003
|
const result = await permissionChange(peer, async () => {
|
|
5892
6004
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
5893
6005
|
const selected = permissionMode(peer);
|
|
5894
|
-
if (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" };
|
|
6006
|
+
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" };
|
|
5895
6007
|
if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
|
|
5896
|
-
if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
6008
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
5897
6009
|
else await owner.setPermissionMode(selected);
|
|
5898
6010
|
}
|
|
5899
6011
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -5972,7 +6084,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5972
6084
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
5973
6085
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
5974
6086
|
ws.claim(sock, msg.channel === false);
|
|
5975
|
-
if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
6087
|
+
if (declarations[c.peer] && isFixedBuiltinDefinition(definitionOf(c.peer)) && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
5976
6088
|
revokeTerminalExecution(c.peer);
|
|
5977
6089
|
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5978
6090
|
try {
|
|
@@ -1,42 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { extractNodes, nodePath, type View } from "@staix/agent-mem";
|
|
1
|
+
import { extractNodes, planGuardNodes as libraryPlanGuardNodes, matchingPlanFails as libraryMatchingPlanFails, type PlanGuardContext, type View } from "@staix/agent-mem";
|
|
3
2
|
import type { BriefTask } from "../memory/brief.ts";
|
|
3
|
+
export type { PlanGuardContext } from "@staix/agent-mem";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/** Explicit plan/ref names only, with no filesystem probes or free-text inference. */
|
|
5
|
+
/** Preserve the hub task-shaped API while the library owns all matching logic. */
|
|
8
6
|
export function planGuardNodes(task: BriefTask, context: PlanGuardContext): string[] {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const node of extractNodes("", { ...context, paths: [path], checkExists: false })) nodes.add(node);
|
|
14
|
-
}
|
|
15
|
-
const onlyPath = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
|
|
16
|
-
for (const symbol of task.plan?.symbols ?? []) {
|
|
17
|
-
if (typeof symbol !== "string") continue;
|
|
18
|
-
for (const form of new Set([symbol, normalizeMemorySymbolInput(symbol)])) {
|
|
19
|
-
for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [form], checkExists: false })) nodes.add(node);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return [...nodes].sort();
|
|
7
|
+
return libraryPlanGuardNodes(task, context);
|
|
8
|
+
}
|
|
9
|
+
export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
|
|
10
|
+
return libraryMatchingPlanFails(task, notes, context);
|
|
23
11
|
}
|
|
12
|
+
|
|
24
13
|
/** Retrieval nodes retain the title/task context as well as the explicit plan query. */
|
|
25
14
|
export function taskMemoryQueryNodes(task: BriefTask, context: PlanGuardContext): string[] {
|
|
26
15
|
const ordinary = extractNodes(task.title, { ...context, paths: [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])], ...(task.id !== undefined && task.created !== undefined ? { task: { id: task.id, created: task.created } } : {}) });
|
|
27
16
|
return [...new Set([...ordinary, ...planGuardNodes(task, context)])].sort();
|
|
28
17
|
}
|
|
29
|
-
const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
30
|
-
|
|
31
|
-
/** Fail.about is the source of the graph's rules_out edges in v1; there is no additional stored rules_out field. */
|
|
32
|
-
export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
|
|
33
|
-
const nodes = planGuardNodes(task, context);
|
|
34
|
-
if (!nodes.length) return [];
|
|
35
|
-
return notes.filter(note => note.kind === "fail" && !note.supersededBy && note.about.some(about => nodes.some(node => {
|
|
36
|
-
if (node === about) return true;
|
|
37
|
-
if (node.startsWith("symbol:") && about.startsWith("symbol:")) return false; // Qualified symbols require both name and file identity.
|
|
38
|
-
const path = nodePath(node), other = nodePath(about);
|
|
39
|
-
// File/directory scope intersects at a whole path component, never a lexical substring.
|
|
40
|
-
return !!path && !!other && samePath(path, other);
|
|
41
|
-
})));
|
|
42
|
-
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -653,7 +653,10 @@ export class Tasks {
|
|
|
653
653
|
if (this.isPii(row)) return false;
|
|
654
654
|
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
655
655
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
656
|
-
|
|
656
|
+
// #328: a follow-up that only records an earlier task's finding as fixed never had it; it counts once seen open.
|
|
657
|
+
// By id alone: a reclassified finding keeps its id (task note); a withheld review is never evidence.
|
|
658
|
+
const occurred = row.history.some(entry => !entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id && (value.disposition === "open" || value.disposition === "accepted")));
|
|
659
|
+
return !last?.withheld && finding && same(finding) && occurred && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
657
660
|
});
|
|
658
661
|
if (this.workUnits(holders) < 2) continue;
|
|
659
662
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
@@ -4003,12 +4006,12 @@ export class Tasks {
|
|
|
4003
4006
|
return admitted;
|
|
4004
4007
|
}
|
|
4005
4008
|
|
|
4006
|
-
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness):
|
|
4009
|
+
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
|
|
4007
4010
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4008
4011
|
if (mode === "enforce" && !admission?.ready) {
|
|
4009
4012
|
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
4010
4013
|
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
4011
|
-
return;
|
|
4014
|
+
return false;
|
|
4012
4015
|
}
|
|
4013
4016
|
|
|
4014
4017
|
const r = task.refs;
|
|
@@ -4070,7 +4073,7 @@ export class Tasks {
|
|
|
4070
4073
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
4071
4074
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
4072
4075
|
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
4073
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
4076
|
+
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
4074
4077
|
}
|
|
4075
4078
|
|
|
4076
4079
|
private reviewRequestRevision(task: Task): number {
|
|
@@ -4086,25 +4089,35 @@ export class Tasks {
|
|
|
4086
4089
|
}
|
|
4087
4090
|
|
|
4088
4091
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
4089
|
-
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
|
|
4092
|
+
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
4090
4093
|
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
4091
4094
|
const valid = () => {
|
|
4092
4095
|
const task = this.need(id, false, true);
|
|
4093
|
-
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
4096
|
+
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task) && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
4094
4097
|
return task;
|
|
4095
4098
|
};
|
|
4096
4099
|
const before = valid();
|
|
4097
4100
|
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
4098
4101
|
const task = valid();
|
|
4099
4102
|
const options = this.reviewPoolOptions(task);
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
const
|
|
4103
|
+
// A person-selected reviewer never falls through to an agent the person did not select.
|
|
4104
|
+
const manual = this.manuallyNamedReviewer(task);
|
|
4105
|
+
const selection = manual ? undefined : assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
|
|
4106
|
+
if (!selection?.reviewSelection && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
|
|
4107
|
+
const reviewer = selection?.reviewSelection ? selection.reviewer ?? USER : USER;
|
|
4103
4108
|
valid();
|
|
4104
|
-
const
|
|
4109
|
+
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4110
|
+
: selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
|
|
4111
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
4105
4112
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
4106
|
-
|
|
4107
|
-
|
|
4113
|
+
let offered = false;
|
|
4114
|
+
if (reviewer === USER) {
|
|
4115
|
+
const notice = `task ${this.publicTitle(next)}: blocked: the person must review`;
|
|
4116
|
+
this.releaseNotice(notice, () => { this.d.notify(notice); offered = true; });
|
|
4117
|
+
} else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => { offered = this.sendReview(next, reviewer, "", admission); });
|
|
4118
|
+
if (offered && current() && this.reviewBindingCurrent(reviewer, next.id, { head: this.reviewHead(next), flowRevision: this.reviewRequestRevision(next) })) {
|
|
4119
|
+
try { onReoffered?.(); } catch { /* Unconfirmed host settlement retains the delivery hold. */ }
|
|
4120
|
+
}
|
|
4108
4121
|
return next;
|
|
4109
4122
|
}
|
|
4110
4123
|
|
package/src/memory/brief.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
1
2
|
import type { PeerId } from "../hub/envelope.ts";
|
|
2
3
|
import type { MemoryClient } from "./client.ts";
|
|
3
4
|
|
|
@@ -30,17 +31,20 @@ export async function related(client: MemoryClient, project: string, query: stri
|
|
|
30
31
|
/** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
|
|
31
32
|
export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[]; tier?: string; peer?: string; machine?: string; at?: string }
|
|
32
33
|
export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem[]>;
|
|
34
|
+
export interface IntentBriefContext { source: IntentBriefSource; options: IntentBriefOptions }
|
|
35
|
+
/** Host-owned policy and store binding; the library owns relevance, ordering and text. */
|
|
36
|
+
export type IntentBriefHost = (task: BriefTask) => IntentBriefContext | Promise<IntentBriefContext>;
|
|
33
37
|
export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
40
|
-
* twice in one hub run, except a directly matching plan fail that must accompany a new task.
|
|
40
|
+
* Production task briefs use the library's intent retrieval and rendering with host policy and bindings.
|
|
41
|
+
* The hub applies its per-peer delivery dedupe and item cap to complete library rows, reserving the front
|
|
42
|
+
* for host-screened plan fails even when the library's byte cap omitted them. Injected clients without
|
|
43
|
+
* the library port retain the legacy index/timeline bridge; intent mode retires its related/readNotice path.
|
|
41
44
|
*/
|
|
42
45
|
export class Briefs {
|
|
43
46
|
private readonly seen = new Map<PeerId, Set<string>>();
|
|
47
|
+
private readonly libraryRuns = new Map<PeerId, Promise<unknown>>();
|
|
44
48
|
|
|
45
49
|
constructor(
|
|
46
50
|
private readonly client: MemoryClient | undefined,
|
|
@@ -50,11 +54,47 @@ export class Briefs {
|
|
|
50
54
|
/** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
|
|
51
55
|
private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
|
|
52
56
|
private readonly displayNative?: (items: readonly NativeItem[]) => string[],
|
|
57
|
+
private readonly intent?: IntentBriefHost,
|
|
53
58
|
) {}
|
|
54
59
|
|
|
55
60
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
56
61
|
// A brief snapshots its configured cap once, before either optional source is selected.
|
|
57
62
|
const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
|
|
63
|
+
if (this.intent) {
|
|
64
|
+
// Serialize only this peer's library reads, so awaited policy cannot duplicate a delivered note.
|
|
65
|
+
const previous = this.libraryRuns.get(peer);
|
|
66
|
+
const run = (async () => {
|
|
67
|
+
await previous?.catch(() => undefined);
|
|
68
|
+
if (maximum < 1) return undefined;
|
|
69
|
+
try {
|
|
70
|
+
const context = await this.intent!(task);
|
|
71
|
+
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
72
|
+
this.seen.set(peer, seen);
|
|
73
|
+
const revision = context.options.policyVersion();
|
|
74
|
+
const result = await buildIntentBrief(context.source, context.options);
|
|
75
|
+
if (result.state === "unknown" || context.options.allowed?.() === false || context.options.policyVersion() !== revision) return undefined;
|
|
76
|
+
context.options.assertCurrent?.();
|
|
77
|
+
const lines = result.text.split("\n");
|
|
78
|
+
const rendered = new Map(result.items.map((item, index) => [`mem:${item.id}`, lines[index + 1]!]));
|
|
79
|
+
const selected: NativeItem[] = [];
|
|
80
|
+
// Priority is already screened and sanitized by the host's plan guard. Its advice must survive
|
|
81
|
+
// the standalone library's byte cap or narrower retrieval policy, and recur with the next task.
|
|
82
|
+
for (const item of priority) if (selected.length < maximum && !selected.some(row => row.id === item.id)) selected.push({ ...item, line: rendered.get(item.id) ?? item.line });
|
|
83
|
+
for (const item of result.items) {
|
|
84
|
+
const id = `mem:${item.id}`;
|
|
85
|
+
if (selected.length < maximum && !seen.has(id) && !selected.some(row => row.id === id)) selected.push({ id, line: rendered.get(id)!, kind: item.kind });
|
|
86
|
+
}
|
|
87
|
+
if (!selected.length) return undefined;
|
|
88
|
+
// Preserve complete library rows (including graph neighbours after a seen direct bridge).
|
|
89
|
+
const text = [lines[0] || "Live fail notes for this plan (advice, not a block):", ...selected.map(item => item.line)].join("\n");
|
|
90
|
+
for (const item of selected) seen.add(item.id);
|
|
91
|
+
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
|
|
92
|
+
return text;
|
|
93
|
+
} catch { return undefined; }
|
|
94
|
+
})();
|
|
95
|
+
this.libraryRuns.set(peer, run);
|
|
96
|
+
try { return await run; } finally { if (this.libraryRuns.get(peer) === run) this.libraryRuns.delete(peer); }
|
|
97
|
+
}
|
|
58
98
|
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
59
99
|
this.seen.set(peer, seen);
|
|
60
100
|
let native: NativeItem[] = [];
|