@staix/agent-hub 0.27.0 → 0.28.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 +23 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +16 -1
- package/docs/operations.md +308 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +8 -3
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +338 -112
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +9 -1
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +106 -35
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +304 -56
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/specs.block.md +5 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -38,11 +38,13 @@ import { isDenied, sbplString } from "../local/deny.ts";
|
|
|
38
38
|
import { hostShellPath } from "./host-executable.ts";
|
|
39
39
|
import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
|
|
40
40
|
import { renderHeldHint } from "./delivery-hints.ts";
|
|
41
|
+
import { compactRow, pageTasks, parseListQuery } from "./task-list-page.ts";
|
|
41
42
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
42
43
|
import { MemoryObservations } from "./memory-observations.ts";
|
|
43
44
|
import { createStalenessProbe } from "./memory-staleness.ts";
|
|
44
45
|
import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
|
|
45
46
|
import { planningWipLimits } from "./planning.ts";
|
|
47
|
+
import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
|
|
46
48
|
import { projectedDiff } from "./projected-diff.ts";
|
|
47
49
|
import { listenWithoutPortCollision } from "./listen.ts";
|
|
48
50
|
import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
@@ -54,7 +56,7 @@ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
|
|
|
54
56
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
55
57
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
56
58
|
import { ActivityRing } from "./activity.ts";
|
|
57
|
-
import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
59
|
+
import { JcodeOptionMismatch, JcodeTuiRefusal, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
58
60
|
import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
|
|
59
61
|
import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
|
|
60
62
|
import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
|
|
@@ -104,6 +106,7 @@ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
|
|
|
104
106
|
import { createConfigEditor } from "./config-edit-host.ts";
|
|
105
107
|
import { readEditable } from "./config-edit.ts";
|
|
106
108
|
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
109
|
+
import { SpecTaskInspector } from "./specs-task.ts";
|
|
107
110
|
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
108
111
|
import { TokenWindow } from "./token-window.ts";
|
|
109
112
|
import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
|
|
@@ -193,7 +196,7 @@ import { projectChain } from "@staix/agent-mem";
|
|
|
193
196
|
import { recallFor } from "../memory/recall.ts";
|
|
194
197
|
import { conflictsOf } from "./conflicts.ts";
|
|
195
198
|
import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
196
|
-
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
199
|
+
import { crashPlan, lossNotice, openCrashNotices, readSessions, removeSessions, writeSessions, type CrashNotice, type SessionsFile } from "./crash.ts";
|
|
197
200
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
198
201
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
199
202
|
import { soleWorktree, branchHeads, changedPaths, dirtySubmodules, submodulePaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
@@ -218,6 +221,7 @@ import { SourceRegistry } from "./sources.ts";
|
|
|
218
221
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
|
|
219
222
|
import { nativeRecall } from "../memory/recall.ts";
|
|
220
223
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
224
|
+
import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
|
|
221
225
|
|
|
222
226
|
export interface HubConfig {
|
|
223
227
|
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
@@ -260,6 +264,7 @@ export interface HubConfig {
|
|
|
260
264
|
text_projection?: ProjectionConfig;
|
|
261
265
|
/** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
|
|
262
266
|
corpus: { paths: string[] };
|
|
267
|
+
specs: SpecsConfig;
|
|
263
268
|
catalog_roles?: Record<string, string[]>;
|
|
264
269
|
conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
|
|
265
270
|
budget: BudgetConfig;
|
|
@@ -352,6 +357,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
352
357
|
schedules: [],
|
|
353
358
|
folder_triggers: [],
|
|
354
359
|
corpus: { paths: [] },
|
|
360
|
+
specs: { ...DEFAULT_SPECS_CONFIG, roots: [] },
|
|
355
361
|
checks: { timeout_s: 600 },
|
|
356
362
|
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
357
363
|
snapshots: { enabled: false, keep: 20 },
|
|
@@ -370,14 +376,14 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
370
376
|
export { stateDirFor };
|
|
371
377
|
|
|
372
378
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
373
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
379
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
|
|
374
380
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
375
381
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
376
382
|
|
|
377
383
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
378
384
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
379
385
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
380
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
386
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
381
387
|
|
|
382
388
|
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
383
389
|
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
@@ -483,6 +489,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
483
489
|
resolveProfile(file.profile, file.profile_overrides);
|
|
484
490
|
projectionConfig(file.text_projection);
|
|
485
491
|
corpusConfig(file.corpus);
|
|
492
|
+
if (file.specs !== undefined) parseSpecsConfig(file.specs);
|
|
486
493
|
queueBackpressurePolicy(file.tasks?.queue_backpressure);
|
|
487
494
|
validateOperationalConfig(file);
|
|
488
495
|
for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
|
|
@@ -599,6 +606,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
599
606
|
schedules: schedulesConfig(file.schedules),
|
|
600
607
|
folder_triggers: foldersConfig(file.folder_triggers),
|
|
601
608
|
corpus: corpusConfig(file.corpus),
|
|
609
|
+
specs: parseSpecsConfig(file.specs),
|
|
602
610
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
|
|
603
611
|
...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
|
|
604
612
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
@@ -656,6 +664,8 @@ export interface DaemonOptions {
|
|
|
656
664
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
657
665
|
/** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
|
|
658
666
|
kimiHome?: string;
|
|
667
|
+
/** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
|
|
668
|
+
toolWriteReplayMaxEntries?: number;
|
|
659
669
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
660
670
|
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
661
671
|
projectId?: string;
|
|
@@ -752,6 +762,9 @@ const RESOLUTION_CAUSES: [RegExp, string][] = [
|
|
|
752
762
|
[/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
|
|
753
763
|
[/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
|
|
754
764
|
[/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
|
|
765
|
+
// #1096: a row that already carries a resolution is settled history, not a stale read. `retry` leaves its parent in `failed` for good because the redelivery is a separate row.
|
|
766
|
+
[/already resolved by retry/i, "already resolved by retry: this failed row is settled history, its redelivery is a separate delivery (ahub queue list shows it); nothing is left to do for this row"],
|
|
767
|
+
[/already resolved by (completed|discard)/i, "already resolved: this delivery was already settled by a person or the conductor; nothing is left to do for this row"],
|
|
755
768
|
[/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
|
|
756
769
|
[/already terminal|already resolved/i, "already resolved: the delivery is settled"],
|
|
757
770
|
[/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
|
|
@@ -778,6 +791,7 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
778
791
|
export async function startDaemon(opts: DaemonOptions) {
|
|
779
792
|
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
780
793
|
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
794
|
+
if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
|
|
781
795
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
782
796
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
783
797
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -1268,8 +1282,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1268
1282
|
const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
|
|
1269
1283
|
const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
|
|
1270
1284
|
const startedAt = Date.now();
|
|
1271
|
-
/** What crash recovery did or asks the user to do, for `ahub status
|
|
1272
|
-
const
|
|
1285
|
+
/** What crash recovery did or asks the user to do, for `ahub status` (#1082: `crashView` shows the lines that still stand). */
|
|
1286
|
+
const crashNotices: CrashNotice[] = [];
|
|
1287
|
+
/** A crash was reported this run. It never clears: the Jcode headless hint (#1085) has its own end (`tuiServed`), not the notices'. */
|
|
1288
|
+
let crashReported = false;
|
|
1289
|
+
/** The deliveries the crash left in needs_review, by id; filled below once the journal is open. */
|
|
1290
|
+
const crashLostIds = new Set<string>();
|
|
1273
1291
|
|
|
1274
1292
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
1275
1293
|
let inference: Inference | undefined;
|
|
@@ -1279,7 +1297,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1279
1297
|
const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
|
|
1280
1298
|
// What the crash left in flight, per recipient: opening the journal just marked these needs_review.
|
|
1281
1299
|
const lost = new Map<PeerId, JournalDelivery[]>();
|
|
1282
|
-
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
|
|
1300
|
+
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) { lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]); crashLostIds.add(d.id); }
|
|
1283
1301
|
// Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
|
|
1284
1302
|
// A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
|
|
1285
1303
|
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
|
|
@@ -2033,8 +2051,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2033
2051
|
// Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
|
|
2034
2052
|
if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
|
|
2035
2053
|
return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
|
|
2036
|
-
if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true }];
|
|
2037
|
-
|
|
2054
|
+
if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
|
|
2055
|
+
// #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
|
|
2056
|
+
const network = sandboxNetwork !== false;
|
|
2057
|
+
// A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so such a peer cannot create the task worktree. The hub-made writable worktree is #1115.
|
|
2058
|
+
return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: false,
|
|
2059
|
+
worktreeReason: "git worktree add runs outside the sandbox; this peer cannot create the task worktree (#270; a hub-made worktree is #1115)",
|
|
2060
|
+
...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
|
|
2038
2061
|
testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
|
|
2039
2062
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
2040
2063
|
}));
|
|
@@ -2077,6 +2100,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2077
2100
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
2078
2101
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
2079
2102
|
};
|
|
2103
|
+
type SpecRootSnapshot = { peer: PeerId; root: string; executionRoot: string; verifiedRoot?: string; owner?: PeerAdapter; attached?: PeerAdapter; cwd?: string; socket?: Sock; launchId?: string; generation?: string | number; repo?: string };
|
|
2104
|
+
const specInspectors = new Map<string, SpecTaskInspector>();
|
|
2105
|
+
const specInspectorAt = (root: string): SpecTaskInspector => {
|
|
2106
|
+
const cached = specInspectors.get(root);
|
|
2107
|
+
if (cached) { specInspectors.delete(root); specInspectors.set(root, cached); return cached; }
|
|
2108
|
+
const created = new SpecTaskInspector(root, () => config.specs);
|
|
2109
|
+
specInspectors.set(root, created);
|
|
2110
|
+
if (specInspectors.size > 8) specInspectors.delete(specInspectors.keys().next().value!);
|
|
2111
|
+
return created;
|
|
2112
|
+
};
|
|
2113
|
+
const specRootSnapshot = (task: Task): SpecRootSnapshot => {
|
|
2114
|
+
const peer = task.owner ?? USER, binding = executionOwners.get(peer), execution = executionRoots.get(peer) ?? opts.cwd;
|
|
2115
|
+
const root = binding?.socket && !currentExecutionOwner(peer) ? opts.cwd : execution;
|
|
2116
|
+
const verifiedRoot = ownerWriteRoot(peer), attached = bus.peers.get(peer);
|
|
2117
|
+
return { peer, root, executionRoot: execution, ...(verifiedRoot ? { verifiedRoot } : {}), ...(binding ? { owner: binding.owner, cwd: binding.cwd, ...(binding.socket ? { socket: binding.socket } : {}), ...(binding.launchId ? { launchId: binding.launchId } : {}), ...(binding.generation === undefined ? {} : { generation: binding.generation }) } : {}), ...(attached ? { attached } : {}), ...(task.refs.repo ? { repo: task.refs.repo } : {}) };
|
|
2118
|
+
};
|
|
2119
|
+
const sameSpecRoot = (left: SpecRootSnapshot, right: SpecRootSnapshot): boolean => left.peer === right.peer && left.root === right.root
|
|
2120
|
+
&& left.executionRoot === right.executionRoot && left.verifiedRoot === right.verifiedRoot && left.owner === right.owner && left.attached === right.attached
|
|
2121
|
+
&& left.cwd === right.cwd && left.socket === right.socket && left.launchId === right.launchId && left.generation === right.generation && left.repo === right.repo;
|
|
2122
|
+
const projectSpecs = specInspectorAt(opts.cwd);
|
|
2123
|
+
let specsMissingBlockHintShown = false;
|
|
2080
2124
|
const tasks: Tasks = new Tasks({
|
|
2081
2125
|
releaseWindow,
|
|
2082
2126
|
researchRunners: () => {
|
|
@@ -2163,6 +2207,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2163
2207
|
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
2164
2208
|
return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
|
|
2165
2209
|
},
|
|
2210
|
+
specsConfig: () => config.specs,
|
|
2211
|
+
specNotice: task => {
|
|
2212
|
+
const snapshot = specRootSnapshot(task);
|
|
2213
|
+
return specInspectorAt(snapshot.root).notice(task.refs.paths ?? []);
|
|
2214
|
+
},
|
|
2215
|
+
inspectSpecs: async (task, head, changedFiles, acks) => {
|
|
2216
|
+
const snapshot = specRootSnapshot(task), inspector = specInspectorAt(snapshot.root);
|
|
2217
|
+
const report = await Promise.resolve(inspector.inspect(head, changedFiles, { complete: changedFiles !== undefined, acks }));
|
|
2218
|
+
const current = specRootSnapshot(task), currentTask = board.get(task.id);
|
|
2219
|
+
if (!sameSpecRoot(snapshot, current) || !currentTask || currentTask.owner !== task.owner) throw new PreEffectToolRefusal("task repository scope changed during spec inspection; retry hub_task_done");
|
|
2220
|
+
if (config.specs.enabled !== true || config.specs.review === "off") return undefined;
|
|
2221
|
+
return report;
|
|
2222
|
+
},
|
|
2223
|
+
specsRead: (action, paths) => projectSpecs.read(action, paths),
|
|
2166
2224
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
2167
2225
|
board,
|
|
2168
2226
|
bus,
|
|
@@ -2514,7 +2572,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2514
2572
|
return typeof session === "string" ? session : undefined;
|
|
2515
2573
|
};
|
|
2516
2574
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
2517
|
-
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.
|
|
2575
|
+
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
2518
2576
|
// #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
|
|
2519
2577
|
const doings = new Map<PeerId, DoingTracker>();
|
|
2520
2578
|
const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
|
|
@@ -3244,6 +3302,73 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3244
3302
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3245
3303
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3246
3304
|
let taskOpsInFlight = 0;
|
|
3305
|
+
const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
|
|
3306
|
+
const toolWriteScope = (c: Client, operation: string): string => {
|
|
3307
|
+
const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
3308
|
+
const binding = c.toolsOwner ?? c.externalOwner;
|
|
3309
|
+
return JSON.stringify({ actor, role: c.role ?? "", operation, session: c.session === true, launchId: c.launchId ?? null,
|
|
3310
|
+
generation: binding?.generation ?? (c.peer ? connectionGeneration(c.peer) ?? null : null), owner: binding?.owner.id ?? peer?.id ?? null,
|
|
3311
|
+
executionRoot: c.peer ? executionRoots.get(c.peer) ?? null : null });
|
|
3312
|
+
};
|
|
3313
|
+
const taskWriteId = (value: unknown): number | undefined => {
|
|
3314
|
+
const id = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
|
|
3315
|
+
return Number.isInteger(id) ? id : undefined;
|
|
3316
|
+
};
|
|
3317
|
+
const toolWriteCurrent = (c: Client, sock: Sock, operation: string, payload: Record<string, unknown>, scope: string, result?: Record<string, unknown>): boolean => {
|
|
3318
|
+
try {
|
|
3319
|
+
const actor = c.peer ?? USER;
|
|
3320
|
+
if (!c.authed || c.closed || sock.readyState !== WebSocket.OPEN || !c.role || stopping || recoveryActive() && recoveryPhase !== "preparing"
|
|
3321
|
+
|| c.peer && !membershipAllowed(c.peer) || c.externalOwner && !currentExternalTools(c, true) || c.toolsOwner && !currentImplementerTools(c)) return false;
|
|
3322
|
+
if (c.session && sessionGate(actor, payload)) return false;
|
|
3323
|
+
if (toolWriteScope(c, operation) !== scope || readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
|
|
3324
|
+
|| c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(operation)) return false;
|
|
3325
|
+
const privateContext = actor !== USER && (!!turns.get(actor)?.private || contextPii(actor));
|
|
3326
|
+
const args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
|
|
3327
|
+
const requestedOwner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
|
|
3328
|
+
if (operation === "hub_task_propose" && (!may(actor, "propose") || requestedOwner && PEER_ID.test(requestedOwner)
|
|
3329
|
+
&& ![actor, USER, HUB, "digest"].includes(requestedOwner) && !may(actor, "assign"))) return false;
|
|
3330
|
+
const safePiiProposalReceipt = operation === "hub_task_propose" && actor !== USER && result?.ok === true && typeof result.text === "string"
|
|
3331
|
+
&& /^task #[1-9]\d*: (?:backlog|proposed|in_progress|in_review|approved|changes_requested|cancelled), owner (?:none|[a-z][a-z0-9-]{0,31}), reviewer (?:none|[a-z][a-z0-9-]{0,31})$/.test(result.text);
|
|
3332
|
+
const taskId = operation === "hub_review" ? taskWriteId(args.id)
|
|
3333
|
+
: result?.ok === true && typeof result.text === "string" ? Number(/task #(\d+):/.exec(result.text)?.[1]) : undefined;
|
|
3334
|
+
if (taskId !== undefined) {
|
|
3335
|
+
const task = Number.isSafeInteger(taskId) && taskId > 0 ? board.get(taskId) : undefined;
|
|
3336
|
+
if (!task) {
|
|
3337
|
+
// Let the original Tasks.review validation return its specific pre-effect refusal for a new/bad target.
|
|
3338
|
+
// A successful cached verdict without its task row is no longer safe to replay.
|
|
3339
|
+
if (operation !== "hub_review" || result?.ok === true) return false;
|
|
3340
|
+
} else {
|
|
3341
|
+
const piiTask = tasks.isPii(task);
|
|
3342
|
+
// ponytail: non-console review/PII replays remain held; narrower privacy needs host-proven per-operation private-turn authority.
|
|
3343
|
+
if (operation === "hub_review" && actor !== USER && privateContext) return false;
|
|
3344
|
+
if (actor !== USER && piiTask && !safePiiProposalReceipt
|
|
3345
|
+
|| operation === "hub_review" && actor !== USER && task.reviewer !== actor
|
|
3346
|
+
|| actor !== USER && result?.ok === true && typeof result.text === "string" && !tasks.publicMetadataText(result.text) && !safePiiProposalReceipt) return false;
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
if (actor !== USER && operation === "hub_task_propose" && result?.ok === true && typeof result.text === "string") {
|
|
3350
|
+
const overlapLines = result.text.split(/\r?\n/).filter(line => line.startsWith("Overlaps "));
|
|
3351
|
+
if (overlapLines.length > 1) return false;
|
|
3352
|
+
if (overlapLines.length === 1) {
|
|
3353
|
+
const line = overlapLines[0]!;
|
|
3354
|
+
const markers = [...line.matchAll(/#([1-9]\d*) \(owner ([^)\r\n]{1,128})\)/g)];
|
|
3355
|
+
const markerPrefixes = [...line.matchAll(/#([^()\r\n]{1,128}) \(owner /g)];
|
|
3356
|
+
if (!markers.length || markers.length > 64 || markerPrefixes.length !== markers.length) return false;
|
|
3357
|
+
const refs = new Set<number>();
|
|
3358
|
+
for (const marker of markers) {
|
|
3359
|
+
const id = Number(marker[1]);
|
|
3360
|
+
if (!Number.isSafeInteger(id) || id < 1) return false;
|
|
3361
|
+
refs.add(id);
|
|
3362
|
+
}
|
|
3363
|
+
if (refs.size !== markers.length) return false;
|
|
3364
|
+
const facts = board.taskPrivacySignals([...refs]);
|
|
3365
|
+
if (facts.length !== refs.size || facts.some(task => tasks.isPii(task))) return false;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
if (actor !== USER && typeof result?.error === "string" && result.outcome !== "unknown" && !tasks.publicMetadataText(result.error)) return false;
|
|
3369
|
+
return true;
|
|
3370
|
+
} catch { return false; }
|
|
3371
|
+
};
|
|
3247
3372
|
/**
|
|
3248
3373
|
* #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
|
|
3249
3374
|
* status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
|
|
@@ -3354,6 +3479,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3354
3479
|
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
|
|
3355
3480
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
3356
3481
|
}
|
|
3482
|
+
function inboxHeldMetadata(peer: PeerId) {
|
|
3483
|
+
try { return bus.heldDeliveryHints(peer); }
|
|
3484
|
+
catch { return undefined; } // the inbox pull is already settled; advisory held-row metadata cannot hide its result
|
|
3485
|
+
}
|
|
3357
3486
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
3358
3487
|
const peerArg = (v: unknown, name: string): PeerId | undefined => {
|
|
3359
3488
|
if (v == null || v === "") return undefined;
|
|
@@ -3504,14 +3633,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3504
3633
|
return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
|
|
3505
3634
|
}
|
|
3506
3635
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
3636
|
+
if (inProcess && (op === "hub_task_propose" || op === "hub_review") && a && typeof a === "object" && Object.hasOwn(a, "request_id")) throw new PreEffectToolRefusal("request_id replay keys require the control-protocol task transport");
|
|
3507
3637
|
if (BOUNDED_TASK_TOOLS.has(op)) {
|
|
3508
3638
|
const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
|
|
3509
3639
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
|
|
3510
3640
|
|| (keys.includes("id") ? !Number.isSafeInteger(a.id) || a.id < 1 : typeof a.run !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(a.run))) throw new PreEffectToolRefusal("invalid bounded task request");
|
|
3511
3641
|
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
|
|
3512
3642
|
}
|
|
3643
|
+
if (op === "hub_specs" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
3513
3644
|
if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
|
|
3514
3645
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
3646
|
+
if (op === "hub_task_propose" && by !== USER && (piiTurn || turns.get(by)?.private)) {
|
|
3647
|
+
throw new PreEffectToolRefusal("hub_task_propose is not available while working on a PII task: its text must not leave this machine");
|
|
3648
|
+
}
|
|
3515
3649
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
3516
3650
|
if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
|
|
3517
3651
|
const result = await conductor.execute(by, op, a);
|
|
@@ -3682,6 +3816,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3682
3816
|
return JSON.stringify(publicCheck(receipt));
|
|
3683
3817
|
}
|
|
3684
3818
|
switch (op) {
|
|
3819
|
+
case "hub_specs":
|
|
3820
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "action" && key !== "paths")) throw new PreEffectToolRefusal("hub_specs accepts only action and paths");
|
|
3821
|
+
return JSON.stringify(tasks.specsRead(by, a.action, a.paths));
|
|
3685
3822
|
case "hub_delivery_retry": {
|
|
3686
3823
|
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");
|
|
3687
3824
|
if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
@@ -3689,6 +3826,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3689
3826
|
bus.retryDelivery(by, a.delivery_id);
|
|
3690
3827
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
3691
3828
|
}
|
|
3829
|
+
case "hub_delivery_resolve": {
|
|
3830
|
+
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["delivery_id", "revision", "action", "reason"].includes(key))
|
|
3831
|
+
|| typeof a.delivery_id !== "string" || !a.delivery_id.trim() || a.delivery_id.length > 256
|
|
3832
|
+
|| !Number.isSafeInteger(a.revision) || a.revision < 0 || !["completed", "discard"].includes(a.action)
|
|
3833
|
+
|| typeof a.reason !== "string" || !a.reason.trim() || a.reason.trim().length > 1000 || a.reason.length > 1000
|
|
3834
|
+
|| !tasks.publicMetadataText(a.reason.trim()) || !boundedSafe(a.reason.trim())) throw new PreEffectToolRefusal("hub_delivery_resolve needs only delivery_id, revision (the whole number hub_inbox showed), action completed or discard, and a public reason of 1 to 1000 characters; call hub_inbox for the revision and keep private or sensitive text out of the reason");
|
|
3835
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("the hub is not accepting delivery changes now (recovery or shutdown); run ahub status, then call hub_delivery_resolve again after it recovers");
|
|
3836
|
+
bus.resolveOwnDelivery(by, a.delivery_id, a.revision, a.action, a.reason.trim());
|
|
3837
|
+
return `your held delivery was marked ${a.action}`;
|
|
3838
|
+
}
|
|
3692
3839
|
case "hub_backlog_list":
|
|
3693
3840
|
return JSON.stringify(tasks.backlogList(by));
|
|
3694
3841
|
case "hub_release_new":
|
|
@@ -3764,19 +3911,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3764
3911
|
const t = await tasks.propose(by, a);
|
|
3765
3912
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
3766
3913
|
const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
|
|
3767
|
-
|
|
3914
|
+
const specs = tasks.specNotice(t, by);
|
|
3915
|
+
if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
|
|
3768
3916
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
3769
3917
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
3770
|
-
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
|
|
3918
|
+
return [line(t), specs, tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
|
|
3771
3919
|
}
|
|
3772
3920
|
case "hub_task_accept": {
|
|
3773
3921
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
3774
3922
|
const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
|
|
3923
|
+
const specs = tasks.specNotice(t, by);
|
|
3775
3924
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
3776
3925
|
const silent = tasks.silentFor(t.id);
|
|
3777
3926
|
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
3778
3927
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
3779
|
-
return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
|
|
3928
|
+
return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
|
|
3780
3929
|
}
|
|
3781
3930
|
case "hub_task_cancel": {
|
|
3782
3931
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
@@ -3789,7 +3938,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3789
3938
|
case "hub_task_decline":
|
|
3790
3939
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
3791
3940
|
case "hub_task_done": {
|
|
3792
|
-
const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
|
|
3941
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables, a.spec_ack);
|
|
3793
3942
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
3794
3943
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
3795
3944
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -3798,8 +3947,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3798
3947
|
const last = t.history.at(-1);
|
|
3799
3948
|
if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
|
|
3800
3949
|
const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
|
|
3801
|
-
const
|
|
3802
|
-
|
|
3950
|
+
const doneLine = by === USER ? tasks.resultLine(t, by) : line(t);
|
|
3951
|
+
const result = tasks.isChecking(t.id) ? `${doneLine}; its check is queued or running, and the result comes as a task message` : doneLine;
|
|
3952
|
+
const specReport = by === USER ? "" : tasks.specReportText(t, by);
|
|
3953
|
+
return [result, specReport, extra].filter(Boolean).join("\n");
|
|
3803
3954
|
}
|
|
3804
3955
|
case "hub_sdlc_record":
|
|
3805
3956
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
@@ -3839,7 +3990,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3839
3990
|
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
3840
3991
|
releaseId = release.id;
|
|
3841
3992
|
}
|
|
3842
|
-
|
|
3993
|
+
// #1083: an agent always pages (default 50 compact rows) because the whole board is over every tool-output limit; the person's console keeps the full array unless it names a paging argument.
|
|
3994
|
+
const listQuery = parseListQuery(a, by !== USER);
|
|
3995
|
+
const shown = board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length));
|
|
3996
|
+
const listRow = (t: Task) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; };
|
|
3997
|
+
if (!listQuery) return JSON.stringify(shown.map(listRow));
|
|
3998
|
+
const page = pageTasks(shown, listQuery);
|
|
3999
|
+
return JSON.stringify({ tasks: page.rows.map(t => listQuery.detail ? listRow(t) : compactRow(listRow(t))), total: page.total, ...(page.next ? { next: page.next } : {}) });
|
|
3843
4000
|
}
|
|
3844
4001
|
}
|
|
3845
4002
|
if (op === "task_show" && by !== USER) {
|
|
@@ -4533,6 +4690,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4533
4690
|
}
|
|
4534
4691
|
return checkoutInTick.branch;
|
|
4535
4692
|
};
|
|
4693
|
+
/**
|
|
4694
|
+
* The crash-recovery lines that still stand (#1082), read when status is asked: a line about a peer goes once that peer is attached, and the headline's count is the crash's own deliveries that are
|
|
4695
|
+
* still in needs_review, so it matches `ahub queue list` and reaches zero when the operator has resolved them.
|
|
4696
|
+
*/
|
|
4697
|
+
const crashView = (): string[] => {
|
|
4698
|
+
if (!crashNotices.length) return [];
|
|
4699
|
+
const attached = new Set<string>();
|
|
4700
|
+
for (const notice of crashNotices) if (notice.peer !== undefined && bus.peers.has(notice.peer) && bus.peers.get(notice.peer)!.state !== "offline") attached.add(notice.peer);
|
|
4701
|
+
let inFlight = 0;
|
|
4702
|
+
if (crashLostIds.size) { try { for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review" && crashLostIds.has(row.id)) inFlight++; } catch { inFlight = crashLostIds.size; } }
|
|
4703
|
+
return openCrashNotices(crashNotices, { attached, inFlight });
|
|
4704
|
+
};
|
|
4536
4705
|
const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
|
|
4537
4706
|
const taskNeeds = board.statusTasks();
|
|
4538
4707
|
const ownerIsolation = ownerIsolationProjection();
|
|
@@ -4540,9 +4709,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4540
4709
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
4541
4710
|
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
4542
4711
|
const queueBackpressure = tasks.queuePressureStatus(peerIds);
|
|
4712
|
+
const crashLines = crashView();
|
|
4543
4713
|
const view = {
|
|
4544
4714
|
...(queueBackpressure ? { queueBackpressure } : {}),
|
|
4545
|
-
...(
|
|
4715
|
+
...(crashLines.length ? { crash: crashLines } : {}),
|
|
4546
4716
|
projectId,
|
|
4547
4717
|
instanceId,
|
|
4548
4718
|
rooms: roomCommands.statusView(),
|
|
@@ -4900,34 +5070,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4900
5070
|
} catch (error) { log(`session record not written: ${(error as Error).message}`); }
|
|
4901
5071
|
};
|
|
4902
5072
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
4903
|
-
|
|
5073
|
+
// `peer` marks a line that asks something of that peer's owner (it clears when the peer attaches); a line with neither is what recovery found or did and stays (#1082).
|
|
5074
|
+
const report = (line: string, notice: { peer?: string; inFlight?: true } = {}, shown = line) => { crashNotices.push({ text: line, ...notice }); crashReported = true; notify(`crash recovery: ${shown}`); };
|
|
4904
5075
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
4905
|
-
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
5076
|
+
report("the previous hub run stopped without shutting down", { inFlight: true }, `the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
4906
5077
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
4907
5078
|
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
4908
|
-
if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start
|
|
4909
|
-
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
|
|
5079
|
+
if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`, { peer: step.peer }); continue; }
|
|
5080
|
+
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session", { peer: step.peer }); continue; }
|
|
4910
5081
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
4911
5082
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
4912
5083
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
4913
5084
|
if (step.resume) {
|
|
4914
5085
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4915
5086
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
4916
|
-
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
5087
|
+
report(`pi not resumed (${String(r.error)}); ${step.how}`); // what happened, true after a fresh start too: history. Whether Pi is back is the last line of this branch.
|
|
4917
5088
|
} else report(step.how);
|
|
4918
5089
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
4919
|
-
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
|
|
5090
|
+
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless", { peer: step.peer }); continue; }
|
|
4920
5091
|
const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
4921
5092
|
const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
|
|
4922
|
-
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})
|
|
5093
|
+
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`, fresh.ok ? {} : { peer: step.peer });
|
|
4923
5094
|
continue;
|
|
4924
5095
|
}
|
|
4925
5096
|
if (!step.resume || !autoResume()) {
|
|
4926
|
-
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}
|
|
5097
|
+
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`, { peer: step.peer });
|
|
4927
5098
|
continue;
|
|
4928
5099
|
}
|
|
4929
5100
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4930
|
-
report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}
|
|
5101
|
+
report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`, r.ok ? {} : { peer: step.peer });
|
|
4931
5102
|
}
|
|
4932
5103
|
writeStatus();
|
|
4933
5104
|
};
|
|
@@ -5361,7 +5532,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5361
5532
|
if (!isStartablePeer(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, qwen (when on roster), pi (when enabled) and local" };
|
|
5362
5533
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
5363
5534
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
5364
|
-
if (recoveryActive()) return { ok: false, error:
|
|
5535
|
+
if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
|
|
5365
5536
|
const continuity = definitionOf(peer)?.start.autoStart === by
|
|
5366
5537
|
? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
|
|
5367
5538
|
: undefined;
|
|
@@ -5423,6 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5423
5594
|
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
5424
5595
|
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
5425
5596
|
|
|
5597
|
+
/** The refusal of a start request while recovery holds mutations (#1088): the old sentence first, then whether the peer already runs and the step that works. */
|
|
5598
|
+
const startHeldByRecovery = (peer: unknown): string => {
|
|
5599
|
+
const held = "recovery is holding mutations";
|
|
5600
|
+
if (typeof peer !== "string" || !PEER_ID.test(peer)) return `${held}; no peer was started. Wait for the recovery to finish (ahub recovery status shows where it is), then start the peer again`;
|
|
5601
|
+
const owner = bus.peers.get(peer);
|
|
5602
|
+
if (owner && owner.state !== "offline") return `${held}, and ${peer} is already running, so there is nothing to start. Wait for the recovery to finish (ahub recovery status shows where it is), then check ${peer} with ahub status. If the recovery names ${peer} as blocked, a person repairs it with ahub recovery reattach <operation-id> ${peer}, or ends the hold with ahub recovery resume or abort`;
|
|
5603
|
+
return `${held}, so ${peer} was not started. Wait for the recovery to finish (ahub recovery status shows where it is), then start it with ${startCommand(peer)}`;
|
|
5604
|
+
};
|
|
5605
|
+
|
|
5426
5606
|
async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
|
|
5427
5607
|
operational?.current();
|
|
5428
5608
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -5657,6 +5837,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5657
5837
|
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
5658
5838
|
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
5659
5839
|
toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
|
|
5840
|
+
tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
|
|
5660
5841
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
|
|
5661
5842
|
// #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
|
|
5662
5843
|
// or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
|
|
@@ -6437,6 +6618,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6437
6618
|
return {
|
|
6438
6619
|
id: row.id, peer: row.peer, state: row.state, revision: row.revision,
|
|
6439
6620
|
...roomChat.evidence(row.peer, row.id),
|
|
6621
|
+
...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
|
|
6440
6622
|
...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
|
|
6441
6623
|
createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
6442
6624
|
envelopeIds: originals.map((env) => env.id),
|
|
@@ -7605,6 +7787,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7605
7787
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
7606
7788
|
};
|
|
7607
7789
|
const incompatibleRecoveryTarget = () => {
|
|
7790
|
+
if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
|
|
7608
7791
|
if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
|
|
7609
7792
|
if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
|
|
7610
7793
|
if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
|
|
@@ -7955,9 +8138,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7955
8138
|
const c = sock.data;
|
|
7956
8139
|
let refusalRecorded=false;
|
|
7957
8140
|
const reply = (body: Record<string, unknown>) => {
|
|
8141
|
+
const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
|
|
8142
|
+
&& toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
|
|
8143
|
+
let writeRefusal: string | undefined;
|
|
7958
8144
|
const label=operationalRequestLabel(msg);
|
|
7959
|
-
if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c))
|
|
7960
|
-
if (c.toolsOwner && !currentImplementerTools(c))
|
|
8145
|
+
if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) { writeRefusal = "external instance launch or execution root changed"; body = { ok: false, error: writeRefusal }; }
|
|
8146
|
+
if (c.toolsOwner && !currentImplementerTools(c)) { writeRefusal = "Claude child generation or execution root changed"; body = { ok: false, error: writeRefusal, ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) }; }
|
|
7961
8147
|
if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
|
|
7962
8148
|
if(body.authorityRefused===true) {
|
|
7963
8149
|
if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
|
|
@@ -7965,8 +8151,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7965
8151
|
body={...body,error,refusalAudited:refusalRecorded};
|
|
7966
8152
|
} else body={...body,authorityRefused:false};
|
|
7967
8153
|
}
|
|
8154
|
+
if (writeRequestId && body.requestId !== writeRequestId) {
|
|
8155
|
+
const authority = { ...(body.authorityRefused === true || body.authorityRefused === false ? { authorityRefused: body.authorityRefused } : {}),
|
|
8156
|
+
...(typeof body.authorityScope === "string" ? { authorityScope: body.authorityScope } : {}), ...(typeof body.refusalAudited === "boolean" ? { refusalAudited: body.refusalAudited } : {}) };
|
|
8157
|
+
body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
|
|
8158
|
+
}
|
|
7968
8159
|
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
7969
8160
|
};
|
|
8161
|
+
const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
|
|
8162
|
+
const operation = toolWriteOperation(msg);
|
|
8163
|
+
const payload = { t: "task", op: msg.op, args: msg.args ?? {} };
|
|
8164
|
+
const actor = c.peer ?? USER, args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
|
|
8165
|
+
const knownPreEffectRefusal = operation !== undefined && (
|
|
8166
|
+
readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
|
|
8167
|
+
|| operation === "hub_task_propose" && (!may(actor, "propose") || (() => {
|
|
8168
|
+
const owner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
|
|
8169
|
+
return !!owner && PEER_ID.test(owner) && ![actor, USER, HUB, "digest"].includes(owner) && !may(actor, "assign");
|
|
8170
|
+
})())
|
|
8171
|
+
|| operation === "hub_review" && actor !== USER && (() => {
|
|
8172
|
+
const id = taskWriteId(args.id);
|
|
8173
|
+
if (id === undefined || id < 1) return false;
|
|
8174
|
+
const task = board.get(id);
|
|
8175
|
+
return !!task && task.reviewer !== actor;
|
|
8176
|
+
})()
|
|
8177
|
+
);
|
|
8178
|
+
if (!operation || msg.requestId === undefined || knownPreEffectRefusal) {
|
|
8179
|
+
void run().then(text => reply({ t: "task", ok: true, text }), error => reply(formatError(error)));
|
|
8180
|
+
return;
|
|
8181
|
+
}
|
|
8182
|
+
const scope = toolWriteScope(c, operation);
|
|
8183
|
+
void toolWriteRegistry.run({ id: msg.requestId, scope, bucket: actor, payload,
|
|
8184
|
+
current: result => toolWriteCurrent(c, sock, operation, payload, scope, result) && (() => { try { extraCurrent(); return true; } catch { return false; } })(),
|
|
8185
|
+
}, async () => {
|
|
8186
|
+
try { return { t: "task", ok: true, text: await run() }; }
|
|
8187
|
+
catch (error) {
|
|
8188
|
+
if (error instanceof PreEffectToolRefusal || error instanceof OperationalAuthorityError) return formatError(error);
|
|
8189
|
+
throw error;
|
|
8190
|
+
}
|
|
8191
|
+
}).then(result => reply(result), () => reply(toolWriteUnknownReply(payload, typeof msg.requestId === "string" ? msg.requestId : undefined)));
|
|
8192
|
+
};
|
|
7970
8193
|
if (!c.authed) {
|
|
7971
8194
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
7972
8195
|
if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
|
|
@@ -8186,7 +8409,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8186
8409
|
if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
|
|
8187
8410
|
if (msg.op === "list") {
|
|
8188
8411
|
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
8189
|
-
return void reply({ ok:
|
|
8412
|
+
if (msg.all !== undefined && typeof msg.all !== "boolean") return void reply({ ok: false, error: "all is true or false" });
|
|
8413
|
+
// #1096: a row an operator action already settled (a retry parent left `failed` for good, a discarded or completed row) is history, not open work: hidden unless `all`.
|
|
8414
|
+
const everything = bus.queueList(msg.peer), settled = msg.all === true ? new Set<string>() : bus.settledIds();
|
|
8415
|
+
const shown = everything.filter((row) => !settled.has(row.id));
|
|
8416
|
+
return void reply({ ok: true, deliveries: shown.map((row) => queueView(row,false,c.role!=="console")), ...(shown.length < everything.length ? { hiddenSettled: everything.length - shown.length } : {}) });
|
|
8190
8417
|
}
|
|
8191
8418
|
if (msg.op === "discard") {
|
|
8192
8419
|
// #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
|
|
@@ -8194,10 +8421,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8194
8421
|
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
8195
8422
|
if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
|
|
8196
8423
|
if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
if (msg.
|
|
8424
|
+
// #1096: `states` (a list of queued, needs_review, failed) clears several kinds in one call, and a `failed` row counts only while no resolution settled it. The older single `state` keeps its meaning.
|
|
8425
|
+
const KINDS = ["queued", "needs_review", "failed"] as const;
|
|
8426
|
+
if (msg.states !== undefined && msg.state !== undefined) return void reply({ ok: false, error: "give state or states, not both" });
|
|
8427
|
+
if (msg.states !== undefined && (!Array.isArray(msg.states) || !msg.states.length || msg.states.some((entry: unknown) => !(KINDS as readonly unknown[]).includes(entry)) || new Set(msg.states).size !== msg.states.length)) return void reply({ ok: false, error: `states is a list of ${KINDS.join(", ")}` });
|
|
8428
|
+
if (msg.states === undefined && msg.state !== undefined && msg.state !== "queued" && msg.state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
8429
|
+
const wanted: readonly string[] = msg.states ?? [msg.state === undefined ? "queued" : msg.state];
|
|
8430
|
+
const settledRows = wanted.includes("failed") ? bus.settledIds() : new Set<string>();
|
|
8431
|
+
const rows = bus.queueList(msg.peer).filter((row) => wanted.includes(row.state) && !(row.state === "failed" && settledRows.has(row.id)));
|
|
8432
|
+
const byState = Object.fromEntries(wanted.map((kind) => [kind, rows.filter((row) => row.state === kind).length]));
|
|
8433
|
+
const state = wanted.length === 1 ? wanted[0]! : wanted.join("+");
|
|
8434
|
+
const many = msg.states !== undefined ? { states: wanted, byState } : {};
|
|
8435
|
+
if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded: 0, refused: 0 });
|
|
8201
8436
|
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
8202
8437
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
8203
8438
|
let discarded = 0; const refused: Record<string, number> = {};
|
|
@@ -8206,7 +8441,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8206
8441
|
// discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
|
|
8207
8442
|
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
8208
8443
|
try {
|
|
8209
|
-
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
8444
|
+
if (!current || current.state !== row.state) throw new Error("unknown delivery");
|
|
8210
8445
|
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
|
|
8211
8446
|
if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
|
|
8212
8447
|
discarded++;
|
|
@@ -8214,7 +8449,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8214
8449
|
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
8215
8450
|
}
|
|
8216
8451
|
writeStatus();
|
|
8217
|
-
return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
8452
|
+
return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
8218
8453
|
}
|
|
8219
8454
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
8220
8455
|
if (msg.op === "show") {
|
|
@@ -8248,6 +8483,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8248
8483
|
return void reply({ ok: true });
|
|
8249
8484
|
}
|
|
8250
8485
|
case "inbox": {
|
|
8486
|
+
if (stopping || recoveryActive()) return void reply({ ok: false, error: "the hub is not serving inbox reads now (recovery or shutdown); run ahub status, then call hub_inbox again after it recovers" });
|
|
8251
8487
|
if (c.toolsOwner) {
|
|
8252
8488
|
const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
|
|
8253
8489
|
try {
|
|
@@ -8255,9 +8491,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8255
8491
|
const pending = bus.pendingEnvelopes(peer);
|
|
8256
8492
|
if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
|
|
8257
8493
|
if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
|
|
8258
|
-
const envs = bus.pull(peer);
|
|
8494
|
+
const envs = bus.pull(peer, { allowNeedsReviewHold: true });
|
|
8259
8495
|
if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
|
|
8260
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
8496
|
+
return void reply({ ok: true, envs, held: inboxHeldMetadata(peer), waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
8261
8497
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8262
8498
|
}
|
|
8263
8499
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
@@ -8265,7 +8501,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8265
8501
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
8266
8502
|
if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
8267
8503
|
try {
|
|
8268
|
-
const envs = bus.pull(peer.id);
|
|
8504
|
+
const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
|
|
8269
8505
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
8270
8506
|
peer.lastInboxAt = Date.now();
|
|
8271
8507
|
let polling: ReturnType<InboxPolling["read"]>;
|
|
@@ -8276,7 +8512,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8276
8512
|
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
8277
8513
|
writeStatus();
|
|
8278
8514
|
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
8279
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
8515
|
+
return void reply({ ok: true, envs, held: inboxHeldMetadata(peer.id), waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
8280
8516
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8281
8517
|
}
|
|
8282
8518
|
case "delivery_complete": {
|
|
@@ -8959,8 +9195,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8959
9195
|
}
|
|
8960
9196
|
return void reply({ ok: false, error: "unknown room operation" });
|
|
8961
9197
|
}
|
|
8962
|
-
case "status":
|
|
8963
|
-
|
|
9198
|
+
case "status": {
|
|
9199
|
+
const current = status(c.role === "console", false, true);
|
|
9200
|
+
if ((c.role === "console" || c.role === "tools") && msg.specs === true) {
|
|
9201
|
+
const summary = projectSpecs.status();
|
|
9202
|
+
if (summary) {
|
|
9203
|
+
const missingBlockHint = !specsMissingBlockHintShown && projectSpecs.missingBlock() === true;
|
|
9204
|
+
if (missingBlockHint) specsMissingBlockHintShown = true;
|
|
9205
|
+
return void reply({ t: "status", status: { ...current, specs: { ...summary, ...(missingBlockHint ? { missingBlockHint: true } : {}) } } });
|
|
9206
|
+
}
|
|
9207
|
+
}
|
|
9208
|
+
return void reply({ t: "status", status: current });
|
|
9209
|
+
}
|
|
8964
9210
|
case "cli_audit_ack": {
|
|
8965
9211
|
if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
|
|
8966
9212
|
if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
|
|
@@ -9036,8 +9282,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9036
9282
|
case "native_tui_launch": {
|
|
9037
9283
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
9038
9284
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
9039
|
-
|
|
9040
|
-
|
|
9285
|
+
// #1085: every refusal names its cause class, in the reply and as one hub.log line. The class is a closed word: never a session id, launch id, socket or path.
|
|
9286
|
+
const refuseTui = (code: string, error: string, resumed?: boolean) => { log(`native_tui_launch jcode refused: ${code}${resumed ? " (resumed session)" : ""}`); return void reply({ ok: false, code, ...(resumed ? { resumed: true } : {}), error }); };
|
|
9287
|
+
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer)) return refuseTui("hub_busy", "owned native TUI launch is unavailable");
|
|
9288
|
+
if (peer !== definitionOf(peer)?.kind || !isJcodePeer(owner)) return refuseTui("not_running", "owned native TUI launch is unavailable");
|
|
9289
|
+
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return refuseTui("mcp_stale", "owned native TUI launch is unavailable");
|
|
9041
9290
|
try {
|
|
9042
9291
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
9043
9292
|
const options = msg.args ?? {};
|
|
@@ -9045,7 +9294,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9045
9294
|
return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
|
|
9046
9295
|
} catch (error) {
|
|
9047
9296
|
if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
|
|
9048
|
-
|
|
9297
|
+
if (error instanceof JcodeTuiRefusal) return refuseTui(error.code, "owned Jcode launch or saved session could not be verified; details withheld", owner.resumedSession);
|
|
9298
|
+
return refuseTui("bad_request", "owned Jcode launch or saved session could not be verified; details withheld");
|
|
9049
9299
|
}
|
|
9050
9300
|
}
|
|
9051
9301
|
case "peer_start":
|
|
@@ -9060,7 +9310,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9060
9310
|
return;
|
|
9061
9311
|
case "start":
|
|
9062
9312
|
if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
|
|
9063
|
-
if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error:
|
|
9313
|
+
if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: startHeldByRecovery(msg.peer) });
|
|
9064
9314
|
if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
|
|
9065
9315
|
startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
|
|
9066
9316
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
@@ -9093,10 +9343,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9093
9343
|
// #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
|
|
9094
9344
|
let current: () => void;
|
|
9095
9345
|
try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
(e: Error) => reply({ t: "task", ...operationalFailure(e) }),
|
|
9099
|
-
);
|
|
9346
|
+
runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
|
|
9347
|
+
error => ({ t: "task", ...operationalFailure(error) }));
|
|
9100
9348
|
return;
|
|
9101
9349
|
}
|
|
9102
9350
|
let invocation: AbortController | undefined;
|
|
@@ -9109,14 +9357,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9109
9357
|
invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
|
|
9110
9358
|
}
|
|
9111
9359
|
const run = async () => c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {});
|
|
9112
|
-
(invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9360
|
+
const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9113
9361
|
const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
|
|
9114
9362
|
if (!live) invocation?.abort();
|
|
9115
9363
|
return live;
|
|
9116
|
-
} }, run) : run())
|
|
9117
|
-
|
|
9118
|
-
(
|
|
9119
|
-
|
|
9364
|
+
} }, run) : run());
|
|
9365
|
+
if (invocation) void execute().then(text => reply({ t: "task", ok: true, text }), (error: Error) => reply({ t: "task", ok: false, error: error.message }))
|
|
9366
|
+
.finally(() => { if (c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
|
|
9367
|
+
else runTaskRequest(execute);
|
|
9120
9368
|
return;
|
|
9121
9369
|
case "pause":
|
|
9122
9370
|
case "resume": {
|