@staix/agent-hub 0.26.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 +30 -0
- package/README.md +13 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +8 -4
- package/docs/agent-notes/tasks.md +7 -2
- 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 +19 -2
- package/docs/operations.md +337 -15
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +15 -3
- package/docs/sdlc.md +4 -4
- package/docs/security.md +9 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -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 +344 -116
- 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 +8 -6
- package/src/cli/identity.ts +14 -6
- 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 +112 -36
- 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/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/action-policy.ts +5 -3
- 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 +324 -64
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +15 -9
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/peers.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- 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/presets/confirmation-matrix.json +5 -5
- 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/release-guard.block.md +2 -1
- 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_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);
|
|
@@ -3520,6 +3654,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3520
3654
|
}
|
|
3521
3655
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
3522
3656
|
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
3657
|
+
if (op === "hub_experiment_record" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3523
3658
|
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3524
3659
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
3525
3660
|
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
@@ -3681,6 +3816,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3681
3816
|
return JSON.stringify(publicCheck(receipt));
|
|
3682
3817
|
}
|
|
3683
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));
|
|
3684
3822
|
case "hub_delivery_retry": {
|
|
3685
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");
|
|
3686
3824
|
if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
@@ -3688,6 +3826,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3688
3826
|
bus.retryDelivery(by, a.delivery_id);
|
|
3689
3827
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
3690
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
|
+
}
|
|
3691
3839
|
case "hub_backlog_list":
|
|
3692
3840
|
return JSON.stringify(tasks.backlogList(by));
|
|
3693
3841
|
case "hub_release_new":
|
|
@@ -3734,6 +3882,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3734
3882
|
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3735
3883
|
case "hub_study_show":
|
|
3736
3884
|
return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
|
|
3885
|
+
// #602 (issue 932 T3): the read of the experiment records Tasks already keeps; it runs nothing and reads no file, and a private record is blanked inside the view.
|
|
3886
|
+
case "hub_experiment_show":
|
|
3887
|
+
return JSON.stringify(await tasks.experimentShow(by, a));
|
|
3888
|
+
case "hub_experiment_record":
|
|
3889
|
+
return JSON.stringify(await tasks.experimentRecord(by, a));
|
|
3737
3890
|
case "hub_study_handoff":
|
|
3738
3891
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
|
|
3739
3892
|
return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
|
|
@@ -3758,19 +3911,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3758
3911
|
const t = await tasks.propose(by, a);
|
|
3759
3912
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
3760
3913
|
const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
|
|
3761
|
-
|
|
3914
|
+
const specs = tasks.specNotice(t, by);
|
|
3915
|
+
if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
|
|
3762
3916
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
3763
3917
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
3764
|
-
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");
|
|
3765
3919
|
}
|
|
3766
3920
|
case "hub_task_accept": {
|
|
3767
3921
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
3768
3922
|
const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
|
|
3923
|
+
const specs = tasks.specNotice(t, by);
|
|
3769
3924
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
3770
3925
|
const silent = tasks.silentFor(t.id);
|
|
3771
3926
|
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
3772
3927
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
3773
|
-
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
|
|
3774
3929
|
}
|
|
3775
3930
|
case "hub_task_cancel": {
|
|
3776
3931
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
@@ -3783,7 +3938,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3783
3938
|
case "hub_task_decline":
|
|
3784
3939
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
3785
3940
|
case "hub_task_done": {
|
|
3786
|
-
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);
|
|
3787
3942
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
3788
3943
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
3789
3944
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -3792,8 +3947,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3792
3947
|
const last = t.history.at(-1);
|
|
3793
3948
|
if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
|
|
3794
3949
|
const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
|
|
3795
|
-
const
|
|
3796
|
-
|
|
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");
|
|
3797
3954
|
}
|
|
3798
3955
|
case "hub_sdlc_record":
|
|
3799
3956
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
@@ -3833,7 +3990,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3833
3990
|
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
3834
3991
|
releaseId = release.id;
|
|
3835
3992
|
}
|
|
3836
|
-
|
|
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 } : {}) });
|
|
3837
4000
|
}
|
|
3838
4001
|
}
|
|
3839
4002
|
if (op === "task_show" && by !== USER) {
|
|
@@ -4254,7 +4417,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4254
4417
|
docs: opts.releaseDocEvidence,
|
|
4255
4418
|
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
4256
4419
|
current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
|
|
4257
|
-
|
|
4420
|
+
conductor: () => {
|
|
4421
|
+
const peer = conductorPeer(config.roles);
|
|
4422
|
+
return peer && !turns.get(peer)?.private && !contextPii(peer) ? peer : undefined;
|
|
4423
|
+
},
|
|
4424
|
+
record: receipt => event({ type: "release_authorization", by: receipt.by, authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
|
|
4258
4425
|
});
|
|
4259
4426
|
const terminalReads = terminalOwnerReads();
|
|
4260
4427
|
const terminalLaunches = new TerminalLaunches({
|
|
@@ -4523,6 +4690,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4523
4690
|
}
|
|
4524
4691
|
return checkoutInTick.branch;
|
|
4525
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
|
+
};
|
|
4526
4705
|
const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
|
|
4527
4706
|
const taskNeeds = board.statusTasks();
|
|
4528
4707
|
const ownerIsolation = ownerIsolationProjection();
|
|
@@ -4530,9 +4709,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4530
4709
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
4531
4710
|
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
4532
4711
|
const queueBackpressure = tasks.queuePressureStatus(peerIds);
|
|
4712
|
+
const crashLines = crashView();
|
|
4533
4713
|
const view = {
|
|
4534
4714
|
...(queueBackpressure ? { queueBackpressure } : {}),
|
|
4535
|
-
...(
|
|
4715
|
+
...(crashLines.length ? { crash: crashLines } : {}),
|
|
4536
4716
|
projectId,
|
|
4537
4717
|
instanceId,
|
|
4538
4718
|
rooms: roomCommands.statusView(),
|
|
@@ -4890,34 +5070,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4890
5070
|
} catch (error) { log(`session record not written: ${(error as Error).message}`); }
|
|
4891
5071
|
};
|
|
4892
5072
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
4893
|
-
|
|
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}`); };
|
|
4894
5075
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
4895
|
-
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)` : ""}`);
|
|
4896
5077
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
4897
5078
|
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
4898
|
-
if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start
|
|
4899
|
-
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; }
|
|
4900
5081
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
4901
5082
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
4902
5083
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
4903
5084
|
if (step.resume) {
|
|
4904
5085
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4905
5086
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
4906
|
-
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.
|
|
4907
5088
|
} else report(step.how);
|
|
4908
5089
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
4909
|
-
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; }
|
|
4910
5091
|
const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
4911
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` : "";
|
|
4912
|
-
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 });
|
|
4913
5094
|
continue;
|
|
4914
5095
|
}
|
|
4915
5096
|
if (!step.resume || !autoResume()) {
|
|
4916
|
-
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 });
|
|
4917
5098
|
continue;
|
|
4918
5099
|
}
|
|
4919
5100
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4920
|
-
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 });
|
|
4921
5102
|
}
|
|
4922
5103
|
writeStatus();
|
|
4923
5104
|
};
|
|
@@ -5351,7 +5532,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5351
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" };
|
|
5352
5533
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
5353
5534
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
5354
|
-
if (recoveryActive()) return { ok: false, error:
|
|
5535
|
+
if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
|
|
5355
5536
|
const continuity = definitionOf(peer)?.start.autoStart === by
|
|
5356
5537
|
? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
|
|
5357
5538
|
: undefined;
|
|
@@ -5413,6 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5413
5594
|
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
5414
5595
|
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
5415
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
|
+
|
|
5416
5606
|
async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
|
|
5417
5607
|
operational?.current();
|
|
5418
5608
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -5647,6 +5837,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5647
5837
|
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
5648
5838
|
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
5649
5839
|
toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
|
|
5840
|
+
tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
|
|
5650
5841
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
|
|
5651
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
|
|
5652
5843
|
// or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
|
|
@@ -6427,6 +6618,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6427
6618
|
return {
|
|
6428
6619
|
id: row.id, peer: row.peer, state: row.state, revision: row.revision,
|
|
6429
6620
|
...roomChat.evidence(row.peer, row.id),
|
|
6621
|
+
...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
|
|
6430
6622
|
...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
|
|
6431
6623
|
createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
6432
6624
|
envelopeIds: originals.map((env) => env.id),
|
|
@@ -7595,6 +7787,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7595
7787
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
7596
7788
|
};
|
|
7597
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";
|
|
7598
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";
|
|
7599
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";
|
|
7600
7793
|
if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
|
|
@@ -7945,9 +8138,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7945
8138
|
const c = sock.data;
|
|
7946
8139
|
let refusalRecorded=false;
|
|
7947
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;
|
|
7948
8144
|
const label=operationalRequestLabel(msg);
|
|
7949
|
-
if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c))
|
|
7950
|
-
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"}: {}) }; }
|
|
7951
8147
|
if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
|
|
7952
8148
|
if(body.authorityRefused===true) {
|
|
7953
8149
|
if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
|
|
@@ -7955,8 +8151,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7955
8151
|
body={...body,error,refusalAudited:refusalRecorded};
|
|
7956
8152
|
} else body={...body,authorityRefused:false};
|
|
7957
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
|
+
}
|
|
7958
8159
|
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
7959
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
|
+
};
|
|
7960
8193
|
if (!c.authed) {
|
|
7961
8194
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
7962
8195
|
if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
|
|
@@ -8176,7 +8409,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8176
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"});
|
|
8177
8410
|
if (msg.op === "list") {
|
|
8178
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" });
|
|
8179
|
-
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 } : {}) });
|
|
8180
8417
|
}
|
|
8181
8418
|
if (msg.op === "discard") {
|
|
8182
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
|
|
@@ -8184,10 +8421,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8184
8421
|
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
8185
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" });
|
|
8186
8423
|
if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
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 });
|
|
8191
8436
|
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
8192
8437
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
8193
8438
|
let discarded = 0; const refused: Record<string, number> = {};
|
|
@@ -8196,7 +8441,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8196
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.
|
|
8197
8442
|
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
8198
8443
|
try {
|
|
8199
|
-
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
8444
|
+
if (!current || current.state !== row.state) throw new Error("unknown delivery");
|
|
8200
8445
|
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
|
|
8201
8446
|
if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
|
|
8202
8447
|
discarded++;
|
|
@@ -8204,7 +8449,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8204
8449
|
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
8205
8450
|
}
|
|
8206
8451
|
writeStatus();
|
|
8207
|
-
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 } : {}) });
|
|
8208
8453
|
}
|
|
8209
8454
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
8210
8455
|
if (msg.op === "show") {
|
|
@@ -8238,6 +8483,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8238
8483
|
return void reply({ ok: true });
|
|
8239
8484
|
}
|
|
8240
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" });
|
|
8241
8487
|
if (c.toolsOwner) {
|
|
8242
8488
|
const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
|
|
8243
8489
|
try {
|
|
@@ -8245,9 +8491,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8245
8491
|
const pending = bus.pendingEnvelopes(peer);
|
|
8246
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" });
|
|
8247
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" });
|
|
8248
|
-
const envs = bus.pull(peer);
|
|
8494
|
+
const envs = bus.pull(peer, { allowNeedsReviewHold: true });
|
|
8249
8495
|
if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
|
|
8250
|
-
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) });
|
|
8251
8497
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8252
8498
|
}
|
|
8253
8499
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
@@ -8255,7 +8501,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8255
8501
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
8256
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" });
|
|
8257
8503
|
try {
|
|
8258
|
-
const envs = bus.pull(peer.id);
|
|
8504
|
+
const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
|
|
8259
8505
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
8260
8506
|
peer.lastInboxAt = Date.now();
|
|
8261
8507
|
let polling: ReturnType<InboxPolling["read"]>;
|
|
@@ -8266,7 +8512,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8266
8512
|
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
8267
8513
|
writeStatus();
|
|
8268
8514
|
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
8269
|
-
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 } } : {}) });
|
|
8270
8516
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8271
8517
|
}
|
|
8272
8518
|
case "delivery_complete": {
|
|
@@ -8791,6 +9037,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8791
9037
|
);
|
|
8792
9038
|
return;
|
|
8793
9039
|
}
|
|
9040
|
+
if (msg.op === "authorize-release") {
|
|
9041
|
+
const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
|
|
9042
|
+
if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may authorize a release" });
|
|
9043
|
+
void releaseAuthorizations.authorize(asker, msg.version, msg.commit).then(
|
|
9044
|
+
authorization => reply({ ok: true, authorization }),
|
|
9045
|
+
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
9046
|
+
);
|
|
9047
|
+
return;
|
|
9048
|
+
}
|
|
8794
9049
|
if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
|
|
8795
9050
|
let current: () => void;
|
|
8796
9051
|
try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
|
|
@@ -8831,13 +9086,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8831
9086
|
return;
|
|
8832
9087
|
}
|
|
8833
9088
|
if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
|
|
8834
|
-
if (msg.op === "authorize-release") {
|
|
8835
|
-
void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
|
|
8836
|
-
authorization => reply({ ok: true, authorization }),
|
|
8837
|
-
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
8838
|
-
);
|
|
8839
|
-
return;
|
|
8840
|
-
}
|
|
8841
9089
|
if (msg.op === "queue" || msg.op === "release") {
|
|
8842
9090
|
if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
|
|
8843
9091
|
const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
|
|
@@ -8947,8 +9195,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8947
9195
|
}
|
|
8948
9196
|
return void reply({ ok: false, error: "unknown room operation" });
|
|
8949
9197
|
}
|
|
8950
|
-
case "status":
|
|
8951
|
-
|
|
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
|
+
}
|
|
8952
9210
|
case "cli_audit_ack": {
|
|
8953
9211
|
if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
|
|
8954
9212
|
if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
|
|
@@ -9024,8 +9282,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9024
9282
|
case "native_tui_launch": {
|
|
9025
9283
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
9026
9284
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
9027
|
-
|
|
9028
|
-
|
|
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");
|
|
9029
9290
|
try {
|
|
9030
9291
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
9031
9292
|
const options = msg.args ?? {};
|
|
@@ -9033,7 +9294,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9033
9294
|
return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
|
|
9034
9295
|
} catch (error) {
|
|
9035
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` });
|
|
9036
|
-
|
|
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");
|
|
9037
9299
|
}
|
|
9038
9300
|
}
|
|
9039
9301
|
case "peer_start":
|
|
@@ -9048,7 +9310,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9048
9310
|
return;
|
|
9049
9311
|
case "start":
|
|
9050
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" });
|
|
9051
|
-
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) });
|
|
9052
9314
|
if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
|
|
9053
9315
|
startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
|
|
9054
9316
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
@@ -9081,10 +9343,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9081
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.
|
|
9082
9344
|
let current: () => void;
|
|
9083
9345
|
try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
(e: Error) => reply({ t: "task", ...operationalFailure(e) }),
|
|
9087
|
-
);
|
|
9346
|
+
runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
|
|
9347
|
+
error => ({ t: "task", ...operationalFailure(error) }));
|
|
9088
9348
|
return;
|
|
9089
9349
|
}
|
|
9090
9350
|
let invocation: AbortController | undefined;
|
|
@@ -9097,14 +9357,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9097
9357
|
invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
|
|
9098
9358
|
}
|
|
9099
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 ?? {});
|
|
9100
|
-
(invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9360
|
+
const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9101
9361
|
const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
|
|
9102
9362
|
if (!live) invocation?.abort();
|
|
9103
9363
|
return live;
|
|
9104
|
-
} }, run) : run())
|
|
9105
|
-
|
|
9106
|
-
(
|
|
9107
|
-
|
|
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);
|
|
9108
9368
|
return;
|
|
9109
9369
|
case "pause":
|
|
9110
9370
|
case "resume": {
|