@staix/agent-hub 0.12.19 → 0.12.20
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 +16 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/events.md +4 -1
- package/docs/operations.md +413 -17
- package/docs/quickstart.md +1 -1
- package/docs/security.md +17 -10
- package/docs/smoke.md +58 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +63 -25
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +3 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/console-state.ts +325 -102
- package/src/cli/console.ts +44 -23
- package/src/cli/help.ts +120 -0
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +2 -0
- package/src/cli/main.ts +171 -75
- package/src/cli/preview.ts +1 -0
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +9 -5
- package/src/cli/terminal-recovery.ts +140 -36
- package/src/cli/upgrade-runtime.ts +274 -56
- package/src/cli/upgrade.ts +366 -41
- package/src/hub/ask.ts +1 -1
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +60 -4
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +38 -6
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +56 -9
- package/src/hub/events.ts +7 -1
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +7 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +51 -3
- package/src/hub/report.ts +43 -1
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +236 -83
- package/src/local/deny.ts +2 -1
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/hub/bus.ts
CHANGED
|
@@ -715,13 +715,69 @@ export class Bus {
|
|
|
715
715
|
finally { this.steering--; this.onQueues?.(); }
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
+
/** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
719
|
+
private held(id: PeerId): boolean {
|
|
720
|
+
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
|
|
724
|
+
private remember(envs: Envelope[]): void {
|
|
725
|
+
for (const e of envs) { const kept = this.seen.get(e.id) ?? e; this.seen.delete(e.id); this.seen.set(e.id, kept); }
|
|
726
|
+
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
|
|
730
|
+
private deliverable(id: PeerId): boolean {
|
|
731
|
+
return !this.held(id) && this.stateOf(id) === "idle" && !this.peers.get(id)?.pullOnly;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* What one push delivery would take (the preface, then `take`'s batch), handed to a pull-only peer and recorded as
|
|
736
|
+
* completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
|
|
737
|
+
* `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
|
|
738
|
+
* are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
|
|
739
|
+
* back only this peer's queue and preface (every pause stays) and stops the bus like any other journal failure.
|
|
740
|
+
* ponytail: recorded completed at hand-out, before anything shows the model saw it: a reply lost on the way, a tool
|
|
741
|
+
* call cancelled (Esc) or a plugin that dies loses the batch, and `ahub queue resolve --action retry` refuses a
|
|
742
|
+
* terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
|
|
743
|
+
* steps if loss is ever observed.
|
|
744
|
+
*/
|
|
745
|
+
pull(id: PeerId): Envelope[] | undefined {
|
|
746
|
+
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
747
|
+
if (this.held(id)) return undefined;
|
|
748
|
+
const queue = this.queues.get(id) ?? [];
|
|
749
|
+
this.dropIrrelevant(id, queue);
|
|
750
|
+
const preface = this.prefaces.get(id);
|
|
751
|
+
if (!preface && !queue.length) return [];
|
|
752
|
+
const queued = [...queue];
|
|
753
|
+
const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
|
|
754
|
+
this.prefaces.delete(id);
|
|
755
|
+
// Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
|
|
756
|
+
this.remember(batch);
|
|
757
|
+
try {
|
|
758
|
+
const deliveryId = crypto.randomUUID();
|
|
759
|
+
this.journal?.transaction(() => {
|
|
760
|
+
this.durableHandoff(id, deliveryId, batch, batch); // its bus snapshot is the queue after this pull
|
|
761
|
+
this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
|
|
762
|
+
});
|
|
763
|
+
} catch (error) {
|
|
764
|
+
queue.splice(0, queue.length, ...queued);
|
|
765
|
+
if (preface) this.prefaces.set(id, preface);
|
|
766
|
+
this.storageError = "delivery journal unavailable";
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
|
|
770
|
+
this.onQueues?.();
|
|
771
|
+
return batch;
|
|
772
|
+
}
|
|
773
|
+
|
|
718
774
|
private async drain(id: PeerId): Promise<void> {
|
|
719
775
|
if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
|
|
720
776
|
this.draining.add(id);
|
|
721
777
|
try {
|
|
722
778
|
const peer = this.peers.get(id)!;
|
|
723
779
|
const queue = this.queues.get(id)!;
|
|
724
|
-
while (!this.storageError &&
|
|
780
|
+
while (!this.storageError && queue.length && this.deliverable(id)) {
|
|
725
781
|
if (this.dropIrrelevant(id, queue) && !queue.length) break;
|
|
726
782
|
const delay = this.wait(queue);
|
|
727
783
|
if (delay > 0) { this.arm(id, delay); break; }
|
|
@@ -736,7 +792,8 @@ export class Bus {
|
|
|
736
792
|
this.condensing += mayCondense ? 1 : 0;
|
|
737
793
|
const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
738
794
|
this.condensing -= mayCondense ? 1 : 0;
|
|
739
|
-
|
|
795
|
+
// A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
|
|
796
|
+
if (!this.deliverable(id)) {
|
|
740
797
|
if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
|
|
741
798
|
break;
|
|
742
799
|
}
|
|
@@ -751,8 +808,7 @@ export class Bus {
|
|
|
751
808
|
for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
|
|
752
809
|
this.prefaces.delete(id);
|
|
753
810
|
}
|
|
754
|
-
|
|
755
|
-
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
811
|
+
this.remember(out);
|
|
756
812
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
757
813
|
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
758
814
|
const deliveryId = crypto.randomUUID();
|
package/src/hub/child-process.ts
CHANGED
|
@@ -17,6 +17,7 @@ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.e
|
|
|
17
17
|
delete env.CLAUDECODE;
|
|
18
18
|
delete env.CLAUDE_CODE_SESSION_ID;
|
|
19
19
|
delete env.CODEX_THREAD_ID;
|
|
20
|
+
delete env.AGENTHUB_CHANNEL;
|
|
20
21
|
delete env.AGENTHUB_PEER_ID;
|
|
21
22
|
env.AGENTHUB_PEER_ID = peer;
|
|
22
23
|
return env;
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Task } from "./board.ts";
|
|
3
3
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
|
+
import { HUB, USER } from "./envelope.ts";
|
|
5
|
+
import { OWNERSHIP_EVENTS } from "./tasks.ts";
|
|
4
6
|
import type { HubEvent } from "./events.ts";
|
|
5
7
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
6
8
|
import type { Budget } from "./budget.ts";
|
|
@@ -45,10 +47,14 @@ export function conductorPeer(roles: unknown): string | null {
|
|
|
45
47
|
/** The role check always comes first; default-allow capabilities cannot grant a role. */
|
|
46
48
|
export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
|
|
47
49
|
if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
|
|
48
|
-
if (assign
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if (assign) requireAssign(peer, capabilities);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A peer with an explicit capabilities list needs `assign` in it to move work. */
|
|
54
|
+
function requireAssign(peer: string, capabilities: Record<string, unknown>): void {
|
|
55
|
+
if (!Object.hasOwn(capabilities, peer)) return;
|
|
56
|
+
const caps = capabilities[peer];
|
|
57
|
+
if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export interface ConductorHold { peer: string; actor: string; since: number }
|
|
@@ -154,10 +160,35 @@ export interface ConductorHooks {
|
|
|
154
160
|
audit(event: ConductEvent): void;
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/** A task id as `Tasks.need()` takes it: a whole number or a digit string. */
|
|
164
|
+
const taskId = (v: unknown): number | undefined => {
|
|
165
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : NaN;
|
|
166
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
167
|
+
};
|
|
168
|
+
|
|
157
169
|
export class Conductor {
|
|
158
170
|
constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
|
|
159
171
|
async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
160
172
|
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new Error("unknown conductor tool");
|
|
173
|
+
if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
|
|
174
|
+
const id = taskId(args.id);
|
|
175
|
+
const task = id === undefined ? undefined : this.hooks.task(id);
|
|
176
|
+
// A task's current owner and reviewer read its public view without the role (#208); nobody else does.
|
|
177
|
+
if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
178
|
+
// Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
|
|
179
|
+
// worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
|
|
180
|
+
// reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
|
|
181
|
+
// moves are not decisions).
|
|
182
|
+
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
|
|
183
|
+
const accepted = task?.history.some((h) => h.event === "accepted");
|
|
184
|
+
if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
185
|
+
const peer = peerId(args.peer);
|
|
186
|
+
if (peer !== actor) requireAssign(actor, this.hooks.capabilities());
|
|
187
|
+
await this.hooks.assign(actor, task.id, peer);
|
|
188
|
+
const updated = this.hooks.task(task.id);
|
|
189
|
+
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
161
192
|
requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate");
|
|
162
193
|
const action = tool.slice(4);
|
|
163
194
|
const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
|
|
@@ -165,8 +196,9 @@ export class Conductor {
|
|
|
165
196
|
};
|
|
166
197
|
if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
|
|
167
198
|
if (tool.startsWith("hub_task_")) {
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
const id = taskId(args.id);
|
|
200
|
+
if (id === undefined) throw new Error("id must be a positive integer");
|
|
201
|
+
const task = this.hooks.task(id);
|
|
170
202
|
if (!task) throw new Error(`no task #${args.id}`);
|
|
171
203
|
if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
|
|
172
204
|
if (tool === "hub_task_assign") {
|
|
@@ -6,15 +6,17 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
9
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 16;
|
|
11
11
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
|
|
12
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
13
13
|
|
|
14
14
|
export interface Hello {
|
|
15
15
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
16
16
|
role: "peer" | "console" | "tools";
|
|
17
17
|
peer?: string;
|
|
18
|
+
/** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
|
|
19
|
+
channel?: boolean;
|
|
18
20
|
projectId?: string;
|
|
19
21
|
instanceId?: string;
|
|
20
22
|
projectRoot?: string;
|
package/src/hub/daemon.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
|
18
18
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
19
19
|
import { PiPeer } from "../adapters/pi.ts";
|
|
20
20
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
21
|
-
import type
|
|
21
|
+
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
22
22
|
import { PiToolReceipts } from "../pi/tool-receipts.ts";
|
|
23
23
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
24
24
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
@@ -42,7 +42,7 @@ import { SupervisionFeed } from "./supervision.ts";
|
|
|
42
42
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
43
43
|
import { launcherPreview } from "../cli/preview.ts";
|
|
44
44
|
import { Tasks } from "./tasks.ts";
|
|
45
|
-
import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./inference.ts";
|
|
45
|
+
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
46
46
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
47
47
|
import { currentRouting, detectSignals } from "./routing.ts";
|
|
48
48
|
import { Bus } from "./bus.ts";
|
|
@@ -61,7 +61,7 @@ import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, typ
|
|
|
61
61
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
62
62
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
63
63
|
import { changedPaths, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
64
|
-
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
64
|
+
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
65
65
|
|
|
66
66
|
import { ContextWindows, DEFAULT_CONTEXT, claudeContext, type ContextConfig } from "./context-window.ts";
|
|
67
67
|
|
|
@@ -306,10 +306,18 @@ class WsPeer extends BasePeer {
|
|
|
306
306
|
private claimed: Sock | undefined;
|
|
307
307
|
private nativeSession: string | undefined;
|
|
308
308
|
private nativeActive = false;
|
|
309
|
+
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
310
|
+
pullOnly = false;
|
|
311
|
+
private claimedPullOnly = false;
|
|
309
312
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
310
|
-
claim(sock: Sock): void {
|
|
313
|
+
claim(sock: Sock, pullOnly = false): void {
|
|
311
314
|
this.claimGeneration = crypto.randomUUID();
|
|
312
315
|
this.claimed = sock;
|
|
316
|
+
this.claimedPullOnly = pullOnly;
|
|
317
|
+
}
|
|
318
|
+
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
319
|
+
get channelHeld(): boolean {
|
|
320
|
+
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
313
321
|
}
|
|
314
322
|
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
315
323
|
* for the id must not take it from a claimant that is still arriving. */
|
|
@@ -325,12 +333,14 @@ class WsPeer extends BasePeer {
|
|
|
325
333
|
this.delivered.clear();
|
|
326
334
|
this.nativeSession = undefined;
|
|
327
335
|
this.nativeActive = false;
|
|
336
|
+
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
328
337
|
this.setState("idle");
|
|
329
338
|
}
|
|
330
339
|
detach(sock: Sock): void {
|
|
331
340
|
if (this.sock !== sock) return;
|
|
332
341
|
this.sock = undefined;
|
|
333
342
|
this.nativeActive = false;
|
|
343
|
+
this.pullOnly = false;
|
|
334
344
|
this.setState("offline");
|
|
335
345
|
}
|
|
336
346
|
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
@@ -663,6 +673,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
663
673
|
}
|
|
664
674
|
},
|
|
665
675
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
676
|
+
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
677
|
+
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model }),
|
|
678
|
+
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
666
679
|
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
667
680
|
review: config.review,
|
|
668
681
|
sweep: taskSweepConfig(config.task_sweep),
|
|
@@ -1280,6 +1293,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1280
1293
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
1281
1294
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
1282
1295
|
};
|
|
1296
|
+
/** Why a paused peer gets nothing; the bus pause itself is the hold. */
|
|
1297
|
+
const pauseReason = (id: PeerId) => {
|
|
1298
|
+
const r = budget.record(id), hold = conductorHolds.get(id);
|
|
1299
|
+
return r ? `paused by the budget coordinator: ${r.reason}; to override: ahub budget resume ${id}` : manualPaused.has(id) ? `paused by the console user; ahub resume ${id}` : hold ? `held by the conductor ${hold.actor}; it releases it with hub_peer_release, or the console user with ahub resume ${id}` : "paused";
|
|
1300
|
+
};
|
|
1283
1301
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
1284
1302
|
const recoveryReady = () => {
|
|
1285
1303
|
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
@@ -1293,7 +1311,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1293
1311
|
// online at the snapshot is still offline, so skipping the comparison cannot hide an
|
|
1294
1312
|
// identity change - it only keeps a detach from wedging readiness forever.
|
|
1295
1313
|
if (now.state === "offline") return true;
|
|
1296
|
-
|
|
1314
|
+
// The coordinator waived this peer's id on the target: an unmanaged Claude session reconnects and can never report
|
|
1315
|
+
// the saved id here (#206), or the operator moved the peer to a fresh session (#215). It still has to reattach.
|
|
1316
|
+
const waived = () => recoveryPhase === "restored" && !!readRecoveryWaivers(opts.stateDir, recoveryOperationId!)[saved.id];
|
|
1317
|
+
if (saved.threadId && saved.threadId !== now.threadId && !waived()) return false;
|
|
1297
1318
|
// Kimi/local rebuild a fresh worker with task context on the target; native
|
|
1298
1319
|
// Claude and Pi sessions must keep their exact identities across restoration.
|
|
1299
1320
|
const freshWorker = recoveryPhase === "restored" && (saved.id === "kimi" || saved.id === "local");
|
|
@@ -1302,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1302
1323
|
// restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
|
|
1303
1324
|
// written before this flag existed are re-derived from disk.
|
|
1304
1325
|
const unpersistedClaude = saved.id === "claude" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
|
|
1305
|
-
if (!unpersistedClaude) return false;
|
|
1326
|
+
if (!unpersistedClaude && !waived()) return false;
|
|
1306
1327
|
}
|
|
1307
1328
|
return true;
|
|
1308
1329
|
});
|
|
@@ -1448,7 +1469,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1448
1469
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
1449
1470
|
codexProxyPort: opts.codexProxyPort,
|
|
1450
1471
|
peers: Object.fromEntries(
|
|
1451
|
-
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
1472
|
+
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { toolsOnly: "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude" } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
1452
1473
|
),
|
|
1453
1474
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
1454
1475
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
@@ -1501,6 +1522,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1501
1522
|
releasing = true;
|
|
1502
1523
|
try {
|
|
1503
1524
|
await tasks.releaseReady(); // #34: dependents a stop cut off between an approval and their assignment
|
|
1525
|
+
await tasks.rescreen().catch((e: Error) => log(`PII re-screen failed: ${e.message}`)); // #198: no verdict yet, nobody has it
|
|
1504
1526
|
if (limit > 0) await releaseOwners(limit);
|
|
1505
1527
|
} finally {
|
|
1506
1528
|
releasing = false;
|
|
@@ -1908,6 +1930,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1908
1930
|
const codex = new CodexPeer("codex", {
|
|
1909
1931
|
onTokens: (added) => void addTokens("codex", added),
|
|
1910
1932
|
onContext: (reading) => contexts.report("codex", reading, contextSession("codex")),
|
|
1933
|
+
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
1934
|
+
onThread: (thread, fresh) => event({ type: "native_thread", peer: "codex", thread, fresh }),
|
|
1911
1935
|
onTurn: (native) => {
|
|
1912
1936
|
const open = turns.get("codex");
|
|
1913
1937
|
if (open) turnLog?.native(open.id, native);
|
|
@@ -1975,7 +1999,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1975
1999
|
if (problem) return { ok: false, error: problem };
|
|
1976
2000
|
modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
|
|
1977
2001
|
routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
2002
|
+
staySwitch: () => currentRouting(opts.cwd, log),
|
|
1978
2003
|
onRoute: record => event({ type: "route", peer: "pi", ...record }),
|
|
2004
|
+
efficientWaitMs: currentRouting(opts.cwd, log).pi.efficient_wait_ms,
|
|
2005
|
+
underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
|
|
2006
|
+
onCooldown: record => {
|
|
2007
|
+
log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
|
|
2008
|
+
event({ type: "cooldown", peer: "pi", ...record });
|
|
2009
|
+
},
|
|
1979
2010
|
});
|
|
1980
2011
|
piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
|
|
1981
2012
|
let piReply: Envelope | undefined;
|
|
@@ -2119,6 +2150,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2119
2150
|
...(route ? { route } : {}),
|
|
2120
2151
|
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
|
2121
2152
|
hubRoutes: () => currentRouting(opts.cwd, log).hub_routes ?? {},
|
|
2153
|
+
staySwitch: () => currentRouting(opts.cwd, log),
|
|
2122
2154
|
onRoute: record => event({ type: "route", peer: "local", ...record }),
|
|
2123
2155
|
onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
|
|
2124
2156
|
onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
|
|
@@ -2484,7 +2516,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2484
2516
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
2485
2517
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
2486
2518
|
const ws = peer;
|
|
2487
|
-
|
|
2519
|
+
// Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
|
|
2520
|
+
if (msg.channel === false && ws.channelHeld) return sock.close(4000, "a session with channel pushes holds this peer");
|
|
2521
|
+
ws.claim(sock, msg.channel === false);
|
|
2488
2522
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
2489
2523
|
void ensurePreface(c.peer).finally(() => {
|
|
2490
2524
|
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
@@ -2516,9 +2550,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2516
2550
|
case "delivery_receipt": {
|
|
2517
2551
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
2518
2552
|
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
2519
|
-
|
|
2553
|
+
const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
2554
|
+
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
2520
2555
|
return void reply({ ok: true });
|
|
2521
2556
|
}
|
|
2557
|
+
case "inbox": {
|
|
2558
|
+
// A Claude session that cannot show channel pushes reads its queue here; nothing it reads is ever `accepted` (issue #205).
|
|
2559
|
+
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
2560
|
+
if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
2561
|
+
if (!peer.pullOnly) return void reply({ ok: false, error: "messages are pushed to this session; hub_inbox only drains failed pushes" });
|
|
2562
|
+
try {
|
|
2563
|
+
const envs = bus.pull(peer.id);
|
|
2564
|
+
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
2565
|
+
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
2566
|
+
return void reply({ ok: true, envs, waiting: bus.queued(peer.id) });
|
|
2567
|
+
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
2568
|
+
}
|
|
2522
2569
|
case "delivery_complete": {
|
|
2523
2570
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
2524
2571
|
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
|
package/src/hub/events.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
2
|
import type { TaskAttribution } from "./attribution.ts";
|
|
3
|
+
import type { SwitchTrace } from "../models/route/stage.ts";
|
|
4
|
+
import type { ScreenRecord } from "./inference.ts";
|
|
3
5
|
|
|
4
6
|
/** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
|
|
5
7
|
export const EVENTS_SCHEMA = 1;
|
|
@@ -15,7 +17,8 @@ export type HubEvent =
|
|
|
15
17
|
| { type: "quiet"; id: string; from: string; peers: string[] }
|
|
16
18
|
| { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; named: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
|
|
17
19
|
| { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
|
|
18
|
-
| { type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default"; score: number; ms: number; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number }
|
|
20
|
+
| ({ type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin"; score: number; ms: number; stateless?: boolean; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number } & Partial<SwitchTrace>)
|
|
21
|
+
| { type: "cooldown"; peer: string; alias: string; event: "start" | "end"; failures: number; ms?: number }
|
|
19
22
|
| { type: "route_outcome"; peer: string; decision: string; turnId: string; turn: "completed" | "failed"; task?: number; pii: boolean; latched: boolean; next?: { severity: number; tests: "pass" | "fail" | "none"; repeat: boolean }; advisor?: "approve" | "redo" | "failed" }
|
|
20
23
|
| { type: "advisor"; peer: string; route: string; trigger: string; verdict: "approve" | "redo" | "failed"; discardedChars: number }
|
|
21
24
|
| { type: "progress"; peer: string; task: number; severity: number; spinning: number; exploring: number; production: number }
|
|
@@ -24,6 +27,7 @@ export type HubEvent =
|
|
|
24
27
|
| { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
25
28
|
| { type: "state"; peer: string; state: string }
|
|
26
29
|
| { type: "turn_start"; peer: string; turn: string }
|
|
30
|
+
| { type: "native_thread"; peer: string; thread: string; fresh: boolean }
|
|
27
31
|
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
|
|
28
32
|
| { type: "native_turn_end"; peer: string; id?: string }
|
|
29
33
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
@@ -37,6 +41,8 @@ export type HubEvent =
|
|
|
37
41
|
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
38
42
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
39
43
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
44
|
+
/** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
|
|
45
|
+
| ({ type: "pii_screen" } & ScreenRecord)
|
|
40
46
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
|
41
47
|
| { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
|
|
42
48
|
| { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
|
|
@@ -83,16 +83,25 @@ export class ExecutionBudget {
|
|
|
83
83
|
return this.admitTasks(taskId === undefined ? [] : [taskId], peer, unit, amount);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** Whether any budget meters this peer's work on these tasks. It only reads: nothing is admitted or counted. */
|
|
87
|
+
applies(taskIds: number[], peer: string): boolean {
|
|
88
|
+
return this.scopes(taskIds, peer).length > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private scopes(taskIds: number[], peer: string): BudgetRow[] {
|
|
92
|
+
const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
|
|
93
|
+
const taskRows = ids.length
|
|
94
|
+
? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
|
|
95
|
+
: [];
|
|
96
|
+
const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
|
|
97
|
+
return [...taskRows, ...runRows];
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
admitTasks(taskIds: number[], peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
|
|
87
101
|
if (!UNITS.has(unit) || !Number.isSafeInteger(amount) || amount <= 0) throw new Error("execution budget admission is invalid");
|
|
88
102
|
const transaction = this.db.transaction(() => {
|
|
89
103
|
// Read scopes only after BEGIN IMMEDIATE so a concurrent configure/admission cannot evade a scope or overdraw it.
|
|
90
|
-
const
|
|
91
|
-
const taskRows = ids.length
|
|
92
|
-
? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
|
|
93
|
-
: [];
|
|
94
|
-
const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
|
|
95
|
-
const rows = [...taskRows, ...runRows].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
|
|
104
|
+
const rows = this.scopes(taskIds, peer).sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
|
|
96
105
|
const pending: { cfg: ExecutionBudgetStatus; updates: Partial<Record<ExecutionUnit, number>>; decisions: ExecutionBudgetDecision[] }[] = rows.map((row) => {
|
|
97
106
|
const cfg = fromRow(row), updates: Partial<Record<ExecutionUnit, number>> = {}, decisions: ExecutionBudgetDecision[] = [];
|
|
98
107
|
const now = Date.now(), elapsedLimit = cfg.limits.elapsed_ms;
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -35,12 +35,12 @@ const tool = (name: string, description: string, properties: Record<string, unkn
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
export const TASK_TOOLS: HubTool[] = [
|
|
38
|
-
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
|
|
38
|
+
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
|
|
39
39
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
|
|
40
40
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
41
41
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
42
42
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
43
|
-
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
43
|
+
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
44
44
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
45
45
|
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
|
|
46
46
|
];
|
|
@@ -50,8 +50,8 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
50
50
|
/** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
|
|
51
51
|
export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
52
52
|
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
53
|
-
tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
|
|
54
|
-
tool("hub_task_assign", "Move a task to another peer, as the conductor.
|
|
53
|
+
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
54
|
+
tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
|
|
55
55
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
56
56
|
tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
|
|
57
57
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|