@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/adapters/pi.ts
CHANGED
|
@@ -99,6 +99,10 @@ export class PiPeer extends BasePeer {
|
|
|
99
99
|
private ownerClaimed = false;
|
|
100
100
|
private ownerToken = "";
|
|
101
101
|
private stopping = true;
|
|
102
|
+
private rosterDetached = false;
|
|
103
|
+
private rosterEpoch = 0;
|
|
104
|
+
/** A personal native turn observed on reattach has no hub reply audience. */
|
|
105
|
+
private rosterUnownedRun = false;
|
|
102
106
|
private exitReported = false;
|
|
103
107
|
private started = false;
|
|
104
108
|
private lastToolName?: string;
|
|
@@ -118,7 +122,7 @@ export class PiPeer extends BasePeer {
|
|
|
118
122
|
if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
|
|
119
123
|
return { sessionId: this.opts.sessionId, sessionFile: this.opts.sessionFile };
|
|
120
124
|
}
|
|
121
|
-
get acceptingTools(): boolean { return !this.stopping; }
|
|
125
|
+
get acceptingTools(): boolean { return !this.stopping && !this.rosterDetached && !this.rosterUnownedRun; }
|
|
122
126
|
/** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
|
|
123
127
|
get recoveryReady(): boolean {
|
|
124
128
|
if (this.state === "busy" || this.starting || this.activeTools) return false;
|
|
@@ -171,13 +175,13 @@ export class PiPeer extends BasePeer {
|
|
|
171
175
|
|
|
172
176
|
/** Envelopes belonging to the currently executing Pi turn, for relay-side model-call admission. */
|
|
173
177
|
/** One id per admitted provider request; parallel tools share its assistant step. */
|
|
174
|
-
get observationTurn(): string | undefined { return this.agentRunning && this.modelStep ? `${this.observationScope}.${this.budgetGeneration}.${this.modelStep}` : undefined; }
|
|
178
|
+
get observationTurn(): string | undefined { return this.acceptingTools && this.agentRunning && this.modelStep ? `${this.observationScope}.${this.budgetGeneration}.${this.modelStep}` : undefined; }
|
|
175
179
|
|
|
176
|
-
get budgetEnvelopes(): Envelope[] { return this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
|
|
180
|
+
get budgetEnvelopes(): Envelope[] { return this.acceptingTools && this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
|
|
177
181
|
|
|
178
182
|
/** Mark an authoritative relay admission stop against the current Pi turn, never a later turn. */
|
|
179
183
|
recordBudgetStop(decision: ExecutionBudgetDecision): { generation: number; reason: string } | undefined {
|
|
180
|
-
if (!this.agentRunning || this.state !== "busy") return undefined;
|
|
184
|
+
if (!this.acceptingTools || !this.agentRunning || this.state !== "busy") return undefined;
|
|
181
185
|
const generation = this.budgetGeneration;
|
|
182
186
|
const reason = `execution budget ${decision.reason ?? "exhausted"}: ${decision.scope} ${decision.unit} used ${decision.used}${decision.limit === null ? "" : ` of ${decision.limit}`}; ${decision.remaining === null ? "remaining unknown" : `${decision.remaining} remaining`}`;
|
|
183
187
|
this.budgetStops.set(generation, reason);
|
|
@@ -192,7 +196,7 @@ export class PiPeer extends BasePeer {
|
|
|
192
196
|
try { await this.starting; } finally { this.starting = undefined; }
|
|
193
197
|
}
|
|
194
198
|
private async handleToolRequest(body: any): Promise<Response> {
|
|
195
|
-
if (this.
|
|
199
|
+
if (!this.acceptingTools || this.state === "offline") return Response.json({ text: this.rosterDetached ? "error: Pi is off roster" : this.rosterUnownedRun ? "error: Pi native turn is pending" : "error: Pi owner is stopped" }, { status: 409 });
|
|
196
200
|
if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
|
|
197
201
|
const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
|
|
198
202
|
this.opts.log?.(`[${this.id}] ${text}`);
|
|
@@ -238,9 +242,9 @@ export class PiPeer extends BasePeer {
|
|
|
238
242
|
try {
|
|
239
243
|
const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
|
|
240
244
|
userBash: body.purpose === "idle_user_bash",
|
|
241
|
-
isCurrent: () => generation === this.budgetGeneration &&
|
|
245
|
+
isCurrent: () => generation === this.budgetGeneration && this.acceptingTools && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
|
|
242
246
|
onApproval: (provenance) => {
|
|
243
|
-
if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
|
|
247
|
+
if (!this.acceptingTools || !this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
|
|
244
248
|
if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
|
|
245
249
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
|
|
246
250
|
}
|
|
@@ -269,7 +273,7 @@ export class PiPeer extends BasePeer {
|
|
|
269
273
|
if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
|
|
270
274
|
}, 10);
|
|
271
275
|
}
|
|
272
|
-
if (this.state === "busy") {
|
|
276
|
+
if (this.state === "busy" && this.acceptingTools) {
|
|
273
277
|
if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
|
|
274
278
|
else this.touch();
|
|
275
279
|
}
|
|
@@ -277,11 +281,12 @@ export class PiPeer extends BasePeer {
|
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
private async handleBudgetRequest(body: any): Promise<Response> {
|
|
284
|
+
if (this.rosterDetached || this.rosterUnownedRun) return Response.json({ error: this.rosterDetached ? "Pi is off roster" : "Pi native turn is pending" }, { status: 409 });
|
|
280
285
|
if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
|
|
281
286
|
const idleUserBash = body.idleUserBash === true;
|
|
282
287
|
const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
|
|
283
|
-
const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature &&
|
|
284
|
-
const idleOwner = this.opts.mode === "tui" &&
|
|
288
|
+
const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && this.acceptingTools && this.verifiedOwner();
|
|
289
|
+
const idleOwner = this.opts.mode === "tui" && this.acceptingTools && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
|
|
285
290
|
if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
|
|
286
291
|
// An interactive user shell has no task delivery. It may spend an eligible run budget only.
|
|
287
292
|
const envs = idleUserBash ? [] : this.budgetEnvelopes;
|
|
@@ -311,6 +316,7 @@ export class PiPeer extends BasePeer {
|
|
|
311
316
|
|
|
312
317
|
private async startImpl(): Promise<void> {
|
|
313
318
|
this.stopping = false;
|
|
319
|
+
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
314
320
|
this.requestedStopReason = undefined;
|
|
315
321
|
this.approvalTurnAbort = false;
|
|
316
322
|
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
@@ -403,8 +409,46 @@ export class PiPeer extends BasePeer {
|
|
|
403
409
|
}
|
|
404
410
|
get mode(): "headless" | "tui" { return this.opts.mode; }
|
|
405
411
|
|
|
412
|
+
/** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
|
|
413
|
+
async detachForRoster(): Promise<void> {
|
|
414
|
+
if (this.opts.mode !== "tui") throw new Error("Pi roster detach requires a personal TUI");
|
|
415
|
+
this.rosterDetached = true; this.rosterEpoch++;
|
|
416
|
+
this.setState("offline");
|
|
417
|
+
clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
|
|
418
|
+
const ids = [...this.activeDeliveryIds]; this.activeDeliveryIds.clear();
|
|
419
|
+
this.activeEnvs = []; this.currentReply = undefined;
|
|
420
|
+
this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
|
|
421
|
+
for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Pi removed from roster before settlement; inspect the native session before retrying" });
|
|
422
|
+
const settling = this.abortTools();
|
|
423
|
+
// Revoke hub commands not yet consumed, and fence an ACK already in flight by rosterEpoch.
|
|
424
|
+
for (const [id, pending] of this.tuiCommands) {
|
|
425
|
+
if (!["prompt", "steer", "set_model"].includes(String(pending.command.type))) continue;
|
|
426
|
+
this.tuiCommands.delete(id); this.tuiQueue = this.tuiQueue.filter((command) => command.id !== id);
|
|
427
|
+
pending.reject(new Error("Pi is off roster"));
|
|
428
|
+
}
|
|
429
|
+
if (settling.length) {
|
|
430
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
431
|
+
try { await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after roster detach")), this.opts.stopGraceMs ?? 5_000); })]); }
|
|
432
|
+
finally { clearTimeout(timer); }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A return reuses only the same verified native owner and a fresh, truthful snapshot. */
|
|
437
|
+
async resumeFromRoster(): Promise<void> {
|
|
438
|
+
if (!this.rosterDetached) return;
|
|
439
|
+
if (this.opts.mode !== "tui" || this.stopping || !this.rosterDetached || !this.server || !this.verifiedOwner()) throw new Error("Pi roster return has no verified live TUI owner");
|
|
440
|
+
const epoch = this.rosterEpoch, session = this.sessionId, file = this.sessionFile;
|
|
441
|
+
const snapshot = await this.sendTui({ type: "get_session_state" });
|
|
442
|
+
if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
|
|
443
|
+
this.rosterDetached = false;
|
|
444
|
+
this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
|
|
445
|
+
this.setState(snapshot.idle ? "idle" : "busy");
|
|
446
|
+
if (!snapshot.idle) this.clearWatchdog();
|
|
447
|
+
}
|
|
448
|
+
|
|
406
449
|
async stop(reason?: string): Promise<void> {
|
|
407
450
|
if (reason !== undefined) this.requestedStopReason = reason;
|
|
451
|
+
this.rosterEpoch++;
|
|
408
452
|
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
409
453
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
410
454
|
this.activeDeliveryIds.clear();
|
|
@@ -482,15 +526,19 @@ export class PiPeer extends BasePeer {
|
|
|
482
526
|
}
|
|
483
527
|
|
|
484
528
|
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
485
|
-
if (this.state !== "idle" || this.
|
|
486
|
-
|
|
487
|
-
|
|
529
|
+
if (this.state !== "idle" || !this.acceptingTools) {
|
|
530
|
+
const reason = `${this.id} is not ready${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
|
|
531
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
532
|
+
throw new Error(reason);
|
|
488
533
|
}
|
|
489
534
|
this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
|
|
535
|
+
const rosterEpoch = this.rosterEpoch;
|
|
490
536
|
let attempted = false;
|
|
491
537
|
try {
|
|
492
538
|
const requested = await this.opts.selectModel?.(envs);
|
|
539
|
+
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
493
540
|
if (requested) await this.setRequestedModel(requested);
|
|
541
|
+
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
494
542
|
this.noteActivity();
|
|
495
543
|
attempted = true;
|
|
496
544
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
@@ -498,6 +546,10 @@ export class PiPeer extends BasePeer {
|
|
|
498
546
|
else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
|
|
499
547
|
if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
|
|
500
548
|
} catch (error) {
|
|
549
|
+
if (rosterEpoch !== this.rosterEpoch) {
|
|
550
|
+
if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
501
553
|
if (!attempted) {
|
|
502
554
|
this.activeEnvs = []; this.setState("idle");
|
|
503
555
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error instanceof Error ? error.message : String(error) });
|
|
@@ -515,10 +567,12 @@ export class PiPeer extends BasePeer {
|
|
|
515
567
|
}
|
|
516
568
|
|
|
517
569
|
async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
518
|
-
if (this.state !== "busy" || this.
|
|
519
|
-
|
|
520
|
-
|
|
570
|
+
if (this.state !== "busy" || !this.acceptingTools || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
|
|
571
|
+
const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
|
|
572
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
573
|
+
throw new Error(reason);
|
|
521
574
|
}
|
|
575
|
+
const rosterEpoch = this.rosterEpoch;
|
|
522
576
|
const reply = replyParent([...this.activeEnvs, ...envs]);
|
|
523
577
|
this.activeEnvs.push(...envs); this.currentReply = reply;
|
|
524
578
|
try {
|
|
@@ -527,12 +581,14 @@ export class PiPeer extends BasePeer {
|
|
|
527
581
|
else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
|
|
528
582
|
if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
|
|
529
583
|
} catch (error) {
|
|
584
|
+
if (rosterEpoch !== this.rosterEpoch) return;
|
|
530
585
|
await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
|
|
531
586
|
}
|
|
532
587
|
}
|
|
533
588
|
|
|
534
589
|
getRequestedModel(): string { return this.requestedModel; }
|
|
535
590
|
async setRequestedModel(model: string): Promise<void> {
|
|
591
|
+
if (!this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
536
592
|
const slash = model.indexOf("/");
|
|
537
593
|
const provider = "agent-hub-local";
|
|
538
594
|
const modelId = model;
|
|
@@ -541,6 +597,24 @@ export class PiPeer extends BasePeer {
|
|
|
541
597
|
}
|
|
542
598
|
|
|
543
599
|
private handleBridgeEvent(event: any): void {
|
|
600
|
+
if (this.rosterDetached || this.rosterUnownedRun) {
|
|
601
|
+
// The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
|
|
602
|
+
// and still-verified owner bind that event to this session; an explicit mismatch never does.
|
|
603
|
+
const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
|
|
604
|
+
if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
|
|
605
|
+
this.budgetGeneration = event.generation;
|
|
606
|
+
this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
|
|
607
|
+
}
|
|
608
|
+
if (!bound && event.type !== "session_shutdown") return;
|
|
609
|
+
}
|
|
610
|
+
if (this.rosterDetached && event.type !== "session_shutdown") return;
|
|
611
|
+
if (this.rosterUnownedRun && event.type !== "session_shutdown") {
|
|
612
|
+
if (event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) {
|
|
613
|
+
this.rosterUnownedRun = false; this.agentRunning = false;
|
|
614
|
+
if (!this.stopping && this.verifiedOwner() && !this.activeTools) this.setState("idle");
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
544
618
|
if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
|
|
545
619
|
if (event.type === "session_start") {
|
|
546
620
|
if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
|
|
@@ -655,7 +729,7 @@ export class PiPeer extends BasePeer {
|
|
|
655
729
|
if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
|
|
656
730
|
});
|
|
657
731
|
}
|
|
658
|
-
private handleRpc(message: RpcMessage): void { if (["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
|
|
732
|
+
private handleRpc(message: RpcMessage): void { if (this.acceptingTools && ["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
|
|
659
733
|
if (message.id !== undefined) { const pending = this.pending.get(String(message.id)); if (pending) { this.pending.delete(String(message.id)); message.success === false ? pending.reject(new Error(message.error ?? "Pi RPC command failed")) : pending.resolve(message); } } }
|
|
660
734
|
private sendRpc(command: Record<string, unknown>): Promise<RpcMessage> { if (!this.proc?.stdin.writable) return Promise.reject(new Error("Pi process is not running")); const id = `ahub-${++this.seq}`; return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Pi RPC ${String(command.type)} timed out`)); }, 30_000); this.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolvePromise(m); }, reject: (e) => { clearTimeout(timer); reject(e); } }); this.proc!.stdin.write(`${JSON.stringify({ id, ...command })}\n`); }); }
|
|
661
735
|
private async waitRpc(command: string, timeout: number): Promise<RpcMessage> {
|
|
@@ -688,7 +762,7 @@ export class PiPeer extends BasePeer {
|
|
|
688
762
|
const envs = this.activeEnvs; this.activeEnvs = [];
|
|
689
763
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
|
|
690
764
|
this.activeDeliveryIds.clear();
|
|
691
|
-
if (envs.length && !this.requestedStopReason) {
|
|
765
|
+
if (envs.length && !this.requestedStopReason && !this.rosterDetached) {
|
|
692
766
|
this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
|
|
693
767
|
if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
|
|
694
768
|
}
|
|
@@ -712,7 +786,7 @@ export class PiPeer extends BasePeer {
|
|
|
712
786
|
}
|
|
713
787
|
private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
|
|
714
788
|
private async endApprovalTurn(generation: number, reason: string): Promise<void> {
|
|
715
|
-
if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.
|
|
789
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || !this.acceptingTools || this.state === "offline") return;
|
|
716
790
|
this.executionAbort?.abort();
|
|
717
791
|
if (this.approvalTurnAbort) {
|
|
718
792
|
try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
|
|
@@ -742,7 +816,7 @@ export class PiPeer extends BasePeer {
|
|
|
742
816
|
}
|
|
743
817
|
}
|
|
744
818
|
protected override onWatchdog(): void {
|
|
745
|
-
if (this.stopping) return;
|
|
819
|
+
if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
|
|
746
820
|
if (this.activeTools) { this.touch(); return; }
|
|
747
821
|
void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
|
|
748
822
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { needs, type Needs, type NeedsInput, type NeedItem } from "../ui/needs.ts";
|
|
2
|
+
import { petFrame, type CharacterId, type PetPose } from "../pet/frame.ts";
|
|
3
|
+
export interface AttentionState {
|
|
4
|
+
approvals: readonly { id: string; peer?: string; expiresAt: number }[];
|
|
5
|
+
permissionDefaults: readonly { peer: string }[];
|
|
6
|
+
peers: Record<string, { state?: string; attached?: boolean; needsReview?: number; heldBy?: string }>;
|
|
7
|
+
budget: Record<string, { paused?: { since?: number; resetsAt?: number } }>;
|
|
8
|
+
attentionMessages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
|
|
9
|
+
storageBlocked?: boolean;
|
|
10
|
+
recoveryNeed?: { id?: string; running: boolean; since?: number };
|
|
11
|
+
queue?: readonly { id: string; peer?: string; state: string }[];
|
|
12
|
+
attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
|
|
13
|
+
}
|
|
14
|
+
/** Allowlisted adapter between console-owned facts and the shared body-free model. */
|
|
15
|
+
export function consoleNeeds(state: AttentionState, now: number): Needs {
|
|
16
|
+
const input: NeedsInput = { approvals: state.approvals.map(row => ({ id: row.id, peer: row.peer, expiresAt: row.expiresAt })), defaults: state.permissionDefaults.map(row => ({ peer: row.peer })), peers: Object.fromEntries(Object.entries(state.peers).map(([peer,row]) => [peer,{ state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }])), budget: Object.fromEntries(Object.entries(state.budget).map(([peer,row]) => [peer,{ ...(row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}) }])), messages: (state.attentionMessages ?? []).map(row => ({ id: row.id, peer: row.peer, important: row.important, since: row.since })), storageBlocked: state.storageBlocked, recovery: state.recoveryNeed };
|
|
17
|
+
const result = needs({ ...input, facts: state.attentionFacts }, now);
|
|
18
|
+
// Panel snapshots know each held row; enrich only ids, without changing the status-derived count.
|
|
19
|
+
const used = new Set<string | number>();
|
|
20
|
+
for (const item of result.items) if (item.kind === "delivery") {
|
|
21
|
+
const eligible = (row: { id: string; peer?: string; state: string }) => row.state === "needs_review" && row.peer === item.peer && !used.has(row.id) && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.id);
|
|
22
|
+
const row = state.queue?.find(row => row.id === item.id && eligible(row)) ?? state.queue?.find(eligible);
|
|
23
|
+
if (row) { item.id = row.id; used.add(row.id); }
|
|
24
|
+
else if (item.id !== undefined) used.add(item.id);
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
export function attentionLine(value: Needs, now = 0, blink = false, next = true): string {
|
|
29
|
+
const label = value.state === "waiting" ? `! ${value.act} need you` : value.state === "blocked" ? `! blocked (${value.act})` : value.state === "review" ? `? ${value.act} for review` : value.state === "working" ? "* working" : value.state === "idle" ? "idle" : "z asleep";
|
|
30
|
+
const eyes = blink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "(- -)" : "(O O)";
|
|
31
|
+
return `${eyes} ${label}${value.watch ? ` +${value.watch}` : ""}${next && value.items.length ? " n next" : ""}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Preserve the shipped line layout; only its trusted built-in face is selectable. */
|
|
35
|
+
export function characterAttentionLine(value: Needs, character: CharacterId = "stai", now = 0, blink = false, next = true, glyphs: "ascii" | "blocks" = "ascii"): string {
|
|
36
|
+
const pose: PetPose = blink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "waitingBlink" : value.state;
|
|
37
|
+
return petFrame(character, pose, "line", glyphs)[0]! + attentionLine(value, now, blink, next).slice(5);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Accept only body-free board facts from the authenticated status reply. */
|
|
41
|
+
export function attentionFacts(raw: unknown): NonNullable<AttentionState["attentionFacts"]> {
|
|
42
|
+
if (!Array.isArray(raw)) return [];
|
|
43
|
+
return raw.flatMap<NonNullable<AttentionState["attentionFacts"]>[number]>(row => {
|
|
44
|
+
if (!row || typeof row !== "object" || !Number.isSafeInteger(row.id) || row.id < 1) return [];
|
|
45
|
+
if (row.kind === "review" && row.level === "act") return [{ kind: "review" as const, id: row.id }];
|
|
46
|
+
if (row.kind === "offline" && row.level === "watch" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offline" as const, id: row.id, peer: row.peer }];
|
|
47
|
+
return [];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function attentionCommand(item: NeedItem): string {
|
|
52
|
+
if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
|
|
53
|
+
if (item.kind === "review") return `ahub task show ${item.id}; ahub review ${item.id} approved|changes_requested`;
|
|
54
|
+
if (item.kind === "offline") return `ahub status; ahub task show ${item.id}; ahub task assign ${item.id} <peer>`;
|
|
55
|
+
if (item.kind === "storage") return "ahub doctor";
|
|
56
|
+
if (item.kind === "recovery") return `ahub recovery status${item.id ? ` ${item.id}` : ""}`;
|
|
57
|
+
if (item.kind === "budget") return "ahub budget";
|
|
58
|
+
if (item.kind === "default") return "ahub permission";
|
|
59
|
+
return item.peer ? `ahub say @${item.peer} <message>` : "ahub console";
|
|
60
|
+
}
|
package/src/cli/bench.ts
CHANGED
|
@@ -5,10 +5,13 @@ import { join } from "node:path";
|
|
|
5
5
|
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
6
|
import { ControlClient } from "../hub/control-client.ts";
|
|
7
7
|
import { readEvents } from "../hub/events.ts";
|
|
8
|
+
import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
|
|
8
9
|
import { taskRecords } from "../hub/research.ts";
|
|
9
|
-
import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
|
|
10
|
+
import { appendBench, BENCH_SCHEMA, memoryMode, type MemoryMode, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
|
|
10
11
|
import { hubHome, realPath } from "../hub/project.ts";
|
|
11
12
|
import { processSignature } from "../pi/process-signature.ts";
|
|
13
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
14
|
+
import { addonState, detectTooling, type Addon, type AddonName } from "../hub/tooling.ts";
|
|
12
15
|
import { VERSION } from "../version.ts";
|
|
13
16
|
|
|
14
17
|
/** What the runner needs from the world, so tests can drive it without a terminal. */
|
|
@@ -23,14 +26,18 @@ export interface BenchIo {
|
|
|
23
26
|
now(): number;
|
|
24
27
|
sleep(ms: number): Promise<void>;
|
|
25
28
|
log(line: string): void;
|
|
29
|
+
/** #248: the add-ons a peer works with; `detectTooling` on this machine unless a test supplies its own. */
|
|
30
|
+
tooling?(peer: string): Addon[] | Promise<Addon[]>;
|
|
31
|
+
/** #258: the memory settings the project's hub runs with; the project config unless a test supplies them. */
|
|
32
|
+
memoryConfig?(): { enabled: boolean; native?: boolean };
|
|
26
33
|
}
|
|
27
|
-
export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
|
|
34
|
+
export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; addons?: Partial<Record<AddonName, "on" | "off">>; memory?: MemoryMode; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
|
|
28
35
|
|
|
29
36
|
const fail = (message: string): never => { throw new Error(message); };
|
|
30
37
|
|
|
31
38
|
/** Commands git runs without a shell; the arguments are ours, never suite text. */
|
|
32
39
|
async function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
|
|
33
|
-
const p = Bun.spawn(
|
|
40
|
+
const p = Bun.spawn(hubGitArgv(args), { cwd, stdout: "pipe", stderr: "ignore", env: hubGitEnv() });
|
|
34
41
|
const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
|
|
35
42
|
return { code, out: out.trim() };
|
|
36
43
|
}
|
|
@@ -191,12 +198,25 @@ export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string>
|
|
|
191
198
|
// ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
|
|
192
199
|
// are recorded as absent. Reading each peer's launch settings would fill them in.
|
|
193
200
|
// The status lists every peer the hub has known; an arm is the ones attached now.
|
|
194
|
-
peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
|
|
195
|
-
.map(([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {})
|
|
201
|
+
peers: (await Promise.all(Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
|
|
202
|
+
.map(async ([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}),
|
|
203
|
+
addons: await (io.tooling ?? ((peer: string) => detectTooling(peer, { cwd: opts.cwd })))(id) })))).sort((a, b) => a.id.localeCompare(b.id)),
|
|
196
204
|
routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
|
|
197
205
|
hubVersion: status.version,
|
|
198
206
|
};
|
|
207
|
+
// #248: a declared add-on condition must hold for the attached peers before anything is recorded or reset.
|
|
208
|
+
const addons = Object.entries(opts.addons ?? {}) as [AddonName, "on" | "off"][];
|
|
209
|
+
for (const [name, want] of addons) {
|
|
210
|
+
const found = addonState(name, fingerprint.peers.map((p) => p.addons));
|
|
211
|
+
if (found !== want) throw new Error(`the arm declares ${name} ${want}, but the attached peers have it ${found}; change their setup or the declaration (nothing was run)`);
|
|
212
|
+
}
|
|
213
|
+
// #258 AC11: the memory the project's hub runs with (its config, read at hub start) must be the arm's.
|
|
214
|
+
// ponytail: read from the project config, not the running hub; a config edited since the hub started reads wrong.
|
|
215
|
+
const runsWith = opts.memory ? memoryMode((io.memoryConfig ?? (() => loadConfig(opts.cwd).memory))()) : undefined;
|
|
216
|
+
if (opts.memory && runsWith !== opts.memory) throw new Error(`the arm declares memory ${opts.memory}, but this project's hub runs with ${runsWith}; change memory.enabled / memory.native and restart the hub, or the declaration (nothing was run)`);
|
|
217
|
+
const condition = { ...(addons.length ? { addons: Object.fromEntries(addons.sort(([a], [b]) => a.localeCompare(b))) } : {}), ...(opts.memory ? { memory: opts.memory } : {}) };
|
|
199
218
|
appendBench({ schema: BENCH_SCHEMA, kind: "run", run, suite: suite.name, suiteHash: suiteHash(suiteText), arm: opts.arm, fingerprint, version: VERSION,
|
|
219
|
+
...(Object.keys(condition).length ? { condition } : {}),
|
|
200
220
|
startedAt: new Date(io.now()).toISOString(), tasks: tasks.length, repeats: opts.repeat, order: tasks.map((t) => t.id), runner: { pid: process.pid, signature: processSignature(process.pid) ?? null } }, home);
|
|
201
221
|
io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
|
|
202
222
|
let stopped: "timeout" | "interrupted" | "error" | undefined;
|
|
@@ -236,7 +256,7 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
|
|
|
236
256
|
// The attempt ends when its outcome is known: the wait for its measures is not part of its time.
|
|
237
257
|
const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
|
|
238
258
|
const ended = extra.ended ?? io.now();
|
|
239
|
-
const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
|
|
259
|
+
const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, arm: opts.arm, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
|
|
240
260
|
hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
|
|
241
261
|
return { attempt, open: extra.open ?? false };
|
|
242
262
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Older same-protocol daemons may ignore newly added filter arguments. */
|
|
2
|
+
export function supportsBoardReleaseFilter(status: unknown): boolean {
|
|
3
|
+
if (!status || typeof status !== "object") return false;
|
|
4
|
+
const capability = (status as Record<string,unknown>).boardReadCapabilities;
|
|
5
|
+
return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).releaseFilter === true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function supportsBoardColumnMetadata(status: unknown): boolean {
|
|
9
|
+
if (!status || typeof status !== "object") return false;
|
|
10
|
+
const capability = (status as Record<string,unknown>).boardReadCapabilities;
|
|
11
|
+
return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).columnMetadata === true;
|
|
12
|
+
}
|