@staix/agent-hub 0.12.2 → 0.12.3

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.
@@ -6,7 +6,9 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
6
6
  import { runTool, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
7
7
  import type { Capture } from "../memory/capture.ts";
8
8
  import type { ChatMessage, ChatResult, OmniRoute } from "../omniroute/client.ts";
9
+ import { safeModelLabel } from "../omniroute/usage.ts";
9
10
  import type { Sidecar } from "../switchyard/sidecar.ts";
11
+ import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
10
12
 
11
13
  export interface LocalOptions {
12
14
  cwd: string;
@@ -21,6 +23,10 @@ export interface LocalOptions {
21
23
  capture?: Capture;
22
24
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
23
25
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
26
+ /** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
27
+ onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string }) => void;
28
+ /** Atomic task/run admission immediately before every model request or tool execution. */
29
+ admitBudget?: (envs: Envelope[], unit: "model_calls" | "tool_calls") => Promise<ExecutionBudgetDecision[]>;
24
30
  /** Per-turn policy from the task the delivery carries: the class's route, and whether it is a PII task. */
25
31
  turnPolicy?: (envs: Envelope[]) => { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined;
26
32
  /** Role contract, appended to the system prompt. */
@@ -35,6 +41,7 @@ const HISTORY_CHARS = 100_000;
35
41
  const TURN_CHARS = 120_000;
36
42
  /** Tools whose effects outlive a failed turn: once one ran, the turn is never redelivered. */
37
43
  const SIDE_EFFECTS = new Set(["write", "edit", "bash", "git", "hub_send"]);
44
+ class ExecutionBudgetStop extends Error { readonly budgetStop = true; }
38
45
  const chars = (msgs: ChatMessage[]) => msgs.reduce((n, m) => n + (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? "").length, 0);
39
46
 
40
47
  const asFunction = (t: { name: string; description: string; inputSchema: unknown }) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } });
@@ -57,6 +64,8 @@ export class LocalPeer extends BasePeer {
57
64
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
58
65
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
59
66
  private abort: AbortController | undefined;
67
+ private budgetTimer?: ReturnType<typeof setTimeout>;
68
+ private budgetStopReason = "";
60
69
  private activeDeliveryId: string | undefined;
61
70
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
62
71
  /** What served the last call, for `ahub status`. */
@@ -83,6 +92,7 @@ export class LocalPeer extends BasePeer {
83
92
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
84
93
  this.activeDeliveryId = undefined;
85
94
  this.turn++;
95
+ clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
86
96
  this.abort?.abort();
87
97
  await this.opts.capture?.end();
88
98
  this.setState("offline");
@@ -95,7 +105,10 @@ export class LocalPeer extends BasePeer {
95
105
  throw new Error(`${this.id} is ${this.state}`);
96
106
  }
97
107
  const turn = ++this.turn;
108
+ this.budgetStopReason = "";
109
+ clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
98
110
  this.activeDeliveryId = deliveryId;
111
+ this.abort = new AbortController();
99
112
  this.setState("busy");
100
113
  if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
101
114
  // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
@@ -115,7 +128,12 @@ export class LocalPeer extends BasePeer {
115
128
  })
116
129
  .catch((e: Error) => {
117
130
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
131
+ if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
118
132
  this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
133
+ if (e instanceof ExecutionBudgetStop) {
134
+ this.reportBudgetStop(e, msgs, progress, reply, !!policy?.pii, deliveryId);
135
+ return;
136
+ }
119
137
  if (!progress.sideEffects) {
120
138
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: e.message });
121
139
  else this.onFailed?.(envs); // legacy delivery: safe to redeliver
@@ -130,6 +148,7 @@ export class LocalPeer extends BasePeer {
130
148
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
131
149
  })
132
150
  .finally(() => {
151
+ if (turn === this.turn) { clearTimeout(this.budgetTimer); this.budgetTimer = undefined; }
133
152
  if (turn === this.turn && this.state === "busy") this.setState("idle");
134
153
  if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
135
154
  });
@@ -139,6 +158,7 @@ export class LocalPeer extends BasePeer {
139
158
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
140
159
  this.activeDeliveryId = undefined;
141
160
  this.turn++;
161
+ clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
142
162
  this.abort?.abort();
143
163
  super.onWatchdog();
144
164
  }
@@ -152,6 +172,7 @@ export class LocalPeer extends BasePeer {
152
172
  reply: EnvelopeOpts,
153
173
  ): Promise<string> {
154
174
  const { maxSteps = 30 } = this.opts;
175
+ const turnSignal = this.abort!.signal;
155
176
  // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
156
177
  const capture = policy?.pii ? undefined : this.opts.capture;
157
178
  // Positive confirmation, and for the path this turn will really take: a sidecar generated against the off-campus URL
@@ -160,22 +181,11 @@ export class LocalPeer extends BasePeer {
160
181
  if (policy?.pii && (viaOffCampus || !(await this.opts.omni.onCampus()))) {
161
182
  return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
162
183
  }
163
- const ctx: ToolContext = {
164
- cwd: this.opts.cwd,
165
- deny: this.opts.tools.deny,
166
- permit: this.opts.tools.permit,
167
- sandboxProfile: this.sandboxProfile,
168
- sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
169
- send: (text, to) => {
170
- const refused = this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
171
- if (typeof refused === "string") return `not sent: ${refused}`;
172
- return policy?.pii ? "sent to the console user only (PII task)" : "sent";
173
- },
174
- };
184
+ const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
175
185
  let usedTools = false;
176
186
  for (let step = 0; step < maxSteps; step++) {
177
187
  this.elide(msgs);
178
- const res = await this.call(msgs, policy);
188
+ const res = await this.call(msgs, policy, envs);
179
189
  if (turn !== this.turn) return "";
180
190
  this.touch();
181
191
  msgs.push(res.message);
@@ -188,6 +198,10 @@ export class LocalPeer extends BasePeer {
188
198
  // A tool has its own timeout (bash up to 600 s) and an approval can take 120 s: neither is the model going silent.
189
199
  const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), 30_000);
190
200
  const name = call.function.name;
201
+ try {
202
+ await this.requireBudget(envs, "tool_calls");
203
+ if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
204
+ } catch (error) { clearInterval(alive); throw error; }
191
205
  const running = TASK_TOOL_NAMES.has(name) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
192
206
  const output = await running.finally(() => clearInterval(alive));
193
207
  if (turn !== this.turn) return "";
@@ -202,32 +216,111 @@ export class LocalPeer extends BasePeer {
202
216
  return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
203
217
  }
204
218
 
219
+ private toolContext(envs: Envelope[], turnSignal: AbortSignal, pii: boolean, reply: EnvelopeOpts): ToolContext {
220
+ return {
221
+ cwd: this.opts.cwd,
222
+ deny: this.opts.tools.deny,
223
+ permit: async (title) => {
224
+ if (turnSignal.aborted) return false;
225
+ return new Promise<boolean>((resolve, reject) => {
226
+ const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
227
+ const onAbort = () => finish(false);
228
+ turnSignal.addEventListener("abort", onAbort, { once: true });
229
+ this.opts.tools.permit(title).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
230
+ });
231
+ },
232
+ sandboxProfile: this.sandboxProfile,
233
+ sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
234
+ signal: turnSignal,
235
+ send: (text, to) => {
236
+ const refused = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
237
+ if (typeof refused === "string") return `not sent: ${refused}`;
238
+ return pii ? "sent to the console user only (PII task)" : "sent";
239
+ },
240
+ };
241
+ }
242
+
243
+ private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, pii: boolean, deliveryId?: string): void {
244
+ this.completeMissingToolResults(msgs, `not run: ${e.message}`);
245
+ const detail = progress.sideEffects
246
+ ? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
247
+ : `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
248
+ msgs.push({ role: "assistant", content: detail });
249
+ if (!pii) this.commit(msgs);
250
+ this.onMessage?.(detail, reply);
251
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
252
+ }
253
+
254
+ private async requireBudget(envs: Envelope[], unit: "model_calls" | "tool_calls"): Promise<void> {
255
+ if (!this.opts.admitBudget) return;
256
+ const decisions = await this.opts.admitBudget(envs, unit);
257
+ const denied = decisions.find((decision) => !decision.allowed);
258
+ if (denied) throw new ExecutionBudgetStop(`execution budget ${denied.reason ?? "exhausted"}: ${denied.scope} ${denied.unit} used ${denied.used}${denied.limit === null ? "" : ` of ${denied.limit}`}`);
259
+ const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
260
+ if (remaining !== undefined) {
261
+ clearTimeout(this.budgetTimer);
262
+ this.budgetTimer = setTimeout(() => {
263
+ this.budgetStopReason = "execution budget exhausted: elapsed_ms wall cap reached";
264
+ this.abort?.abort();
265
+ }, Math.max(0, remaining));
266
+ }
267
+ }
268
+
269
+ /** Preserve valid model history when admission stops in the middle of a parallel tool-call batch. */
270
+ private completeMissingToolResults(msgs: ChatMessage[], result: string): void {
271
+ const called = new Set<string>();
272
+ for (const msg of msgs) if (msg.role === "tool" && msg.tool_call_id) called.add(msg.tool_call_id);
273
+ const missing: string[] = [];
274
+ for (const msg of msgs) if (msg.role === "assistant") for (const call of msg.tool_calls ?? []) if (call.id && !called.has(call.id)) {
275
+ called.add(call.id); missing.push(call.id);
276
+ }
277
+ for (const tool_call_id of missing) msgs.push({ role: "tool", tool_call_id, content: result });
278
+ }
279
+
205
280
  /** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
206
- private async call(turnMsgs: ChatMessage[], policy?: { route?: string; fixedModel?: string }): Promise<ChatResult> {
281
+ private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
207
282
  const { omni, sidecar } = this.opts;
208
283
  // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
209
284
  const route = sidecar ? (policy?.route ?? this.opts.route) : undefined;
210
285
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
211
286
  const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
212
- this.abort = new AbortController();
213
- const signal = this.abort.signal;
287
+ const signal = this.abort!.signal;
214
288
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
215
289
  const via = route ? await sidecar?.endpoint() : undefined;
216
290
  if (via) {
291
+ await this.requireBudget(envs, "model_calls");
217
292
  try {
218
293
  const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
219
294
  this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
295
+ this.recordUsage(res, route!);
220
296
  return res;
221
297
  } catch (e) {
222
298
  if (signal.aborted) throw e;
223
299
  sidecar!.disable((e as Error).message);
224
300
  }
225
301
  }
302
+ await this.requireBudget(envs, "model_calls");
226
303
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal });
227
304
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
305
+ this.recordUsage(res, fixedModel);
228
306
  return res;
229
307
  }
230
308
 
309
+ private recordUsage(res: ChatResult, requestedModel: string): void {
310
+ try {
311
+ this.opts.onUsage?.({
312
+ id: randomUUID(),
313
+ at: new Date().toISOString(),
314
+ ...(res.usage ? { usage: res.usage } : {}),
315
+ requestedModel: safeModelLabel(requestedModel) ?? "unknown",
316
+ ...(safeModelLabel(res.servedModel ?? res.selectedModel) ? { servedModel: safeModelLabel(res.servedModel ?? res.selectedModel)! } : {}),
317
+ ...(safeModelLabel(res.provider) ? { provider: safeModelLabel(res.provider)! } : {}),
318
+ });
319
+ } catch {
320
+ // Usage persistence is optional and must never affect a provider call or turn.
321
+ }
322
+ }
323
+
231
324
  /** A finished turn joins the history; whole old turns (user message up to the next one) fall off the front, so tool calls keep their results. */
232
325
  private commit(msgs: ChatMessage[]): void {
233
326
  this.history.push(...msgs);
@@ -6,6 +6,7 @@ import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId }
6
6
  import { BasePeer } from "../hub/peers.ts";
7
7
  import { stopOwnedProcess } from "../hub/child-process.ts";
8
8
  import { realPath } from "../hub/project.ts";
9
+ import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
9
10
 
10
11
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
11
12
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
@@ -13,9 +14,11 @@ export interface PiRelay { url: string; token: string; models: PiModelDescriptor
13
14
  export interface PiOptions {
14
15
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
15
16
  model?: string;
16
- relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string) => Promise<string>; tools: PiToolSchema[];
17
+ relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
17
18
  preamble?: string;
18
19
  selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
20
+ /** Atomic task/run admission immediately before provider requests or tool execution. */
21
+ admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
19
22
  /** Reported message usage as increments, before agent_settled releases the turn. */
20
23
  onTokens?: (added: number) => void;
21
24
  onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void>;
@@ -51,6 +54,11 @@ export class PiPeer extends BasePeer {
51
54
  private currentReply?: Envelope;
52
55
  private sessionId = "";
53
56
  private sessionFile = "";
57
+ private executionBudgetTimer?: ReturnType<typeof setTimeout>;
58
+ private executionAbort?: AbortController;
59
+ private readonly budgetStops = new Map<number, string>();
60
+ private readonly idleBashReservations = new Map<string, { generation: number; expiresAt: number; deadlineAt?: number }>();
61
+ private budgetGeneration = 0;
54
62
  private emptyResumeVerified = false;
55
63
  private verifiedEmptyResume?: VerifiedEmptyResume;
56
64
  private activityObserved = false;
@@ -132,11 +140,87 @@ export class PiPeer extends BasePeer {
132
140
  this.verifiedEmptyResume = undefined;
133
141
  }
134
142
 
143
+ /** Envelopes belonging to the currently executing Pi turn, for relay-side model-call admission. */
144
+ get budgetEnvelopes(): Envelope[] { return this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
145
+
146
+ /** Mark an authoritative relay admission stop against the current Pi turn, never a later turn. */
147
+ recordBudgetStop(decision: ExecutionBudgetDecision): { generation: number; reason: string } | undefined {
148
+ if (!this.agentRunning || this.state !== "busy") return undefined;
149
+ const generation = this.budgetGeneration;
150
+ 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`}`;
151
+ this.budgetStops.set(generation, reason);
152
+ this.executionAbort?.abort();
153
+ for (const old of this.budgetStops.keys()) if (old < generation - 8) this.budgetStops.delete(old);
154
+ return { generation, reason };
155
+ }
156
+
135
157
  async start(): Promise<void> {
136
158
  if (this.starting) return this.starting;
137
159
  this.starting = this.startImpl();
138
160
  try { await this.starting; } finally { this.starting = undefined; }
139
161
  }
162
+ private async handleToolRequest(body: any): Promise<Response> {
163
+ if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
164
+ let executionSignal = this.executionAbort?.signal;
165
+ let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
166
+ if (body.purpose === "idle_user_bash") {
167
+ const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
168
+ if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
169
+ if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
170
+ this.idleBashReservations.delete(String(body.reservation));
171
+ return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
172
+ }
173
+ this.idleBashReservations.delete(String(body.reservation));
174
+ const idleBashAbort = new AbortController();
175
+ executionSignal = idleBashAbort.signal;
176
+ if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
177
+ }
178
+ this.noteActivity();
179
+ this.activeTools++;
180
+ if (this.state === "idle") this.setState("busy");
181
+ if (this.state === "busy") this.touch();
182
+ try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal) }); }
183
+ catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
184
+ finally {
185
+ clearTimeout(idleBashTimer);
186
+ this.activeTools--;
187
+ if (this.state === "busy") {
188
+ if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
189
+ else this.touch();
190
+ }
191
+ }
192
+ }
193
+
194
+ private async handleBudgetRequest(body: any): Promise<Response> {
195
+ if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
196
+ const idleUserBash = body.idleUserBash === true;
197
+ const current = Number.isSafeInteger(body.generation) && body.generation === this.budgetGeneration;
198
+ const idleOwner = this.ownerClaimed && this.ownerPid !== undefined && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
199
+ if (!current || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
200
+ // An interactive user shell has no task delivery. It may spend an eligible run budget only.
201
+ const envs = idleUserBash ? [] : this.budgetEnvelopes;
202
+ const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
203
+ const denied = decisions.find((decision) => !decision.allowed);
204
+ const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
205
+ let reservation: string | undefined;
206
+ if (idleUserBash && !denied) {
207
+ for (const [key, value] of this.idleBashReservations) if (value.expiresAt < Date.now()) this.idleBashReservations.delete(key);
208
+ reservation = randomUUID();
209
+ this.idleBashReservations.set(reservation, { generation: body.generation, expiresAt: Date.now() + 30_000, ...(remaining === undefined ? {} : { deadlineAt: Date.now() + remaining }) });
210
+ }
211
+ clearTimeout(this.executionBudgetTimer);
212
+ if (!denied && remaining !== undefined && this.state === "busy") {
213
+ const generation = this.budgetGeneration;
214
+ const controller = this.executionAbort;
215
+ this.executionBudgetTimer = setTimeout(() => {
216
+ if (!this.agentRunning || generation !== this.budgetGeneration || controller !== this.executionAbort) return;
217
+ controller?.abort();
218
+ void this.sendTui({ type: "abort_budget", generation }).catch((error) => this.opts.log?.(`[${this.id}] elapsed budget stop could not reach Pi: ${(error as Error).message}`));
219
+ }, Math.max(0, remaining));
220
+ }
221
+ return Response.json({ decisions, ...(reservation ? { reservation } : {}) });
222
+ }
223
+
140
224
  private async startImpl(): Promise<void> {
141
225
  this.stopping = false;
142
226
  mkdirSync(this.opts.stateDir, { recursive: true });
@@ -168,22 +252,8 @@ export class PiPeer extends BasePeer {
168
252
  this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
169
253
  return Response.json({ ok: true });
170
254
  }
171
- if (url.pathname === "/tool") {
172
- if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
173
- this.noteActivity();
174
- this.activeTools++;
175
- if (this.state === "idle") this.setState("busy");
176
- if (this.state === "busy") this.touch();
177
- try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId) }); }
178
- catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
179
- finally {
180
- this.activeTools--;
181
- if (this.state === "busy") {
182
- if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
183
- else this.touch();
184
- }
185
- }
186
- }
255
+ if (url.pathname === "/tool") return this.handleToolRequest(body);
256
+ if (url.pathname === "/budget") return this.handleBudgetRequest(body);
187
257
  return new Response("not found", { status: 404 });
188
258
  } });
189
259
  this.server = bridge;
@@ -340,7 +410,11 @@ export class PiPeer extends BasePeer {
340
410
  this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
341
411
  }
342
412
  if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
343
- if (event.type === "agent_start") { this.usageSeen.clear(); this.noteActivity(); this.agentRunning = true; this.setState("busy"); }
413
+ if (event.type === "agent_start") {
414
+ const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
415
+ if (generation <= this.budgetGeneration) return;
416
+ this.usageSeen.clear(); this.idleBashReservations.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
417
+ }
344
418
  if (event.type === "activity" && this.state === "busy") this.touch();
345
419
  if (event.type === "tokens" && this.state === "busy" && typeof event.id === "string" && event.id.length <= 100 && Number.isSafeInteger(event.tokens) && event.tokens >= 0 && !this.usageSeen.has(event.id)) {
346
420
  this.usageSeen.add(event.id);
@@ -348,24 +422,35 @@ export class PiPeer extends BasePeer {
348
422
  if (event.tokens > 0) this.opts.onTokens?.(event.tokens);
349
423
  }
350
424
  if (event.type === "agent_end") {
425
+ if (Number.isSafeInteger(event.generation) && event.generation !== this.budgetGeneration) return;
426
+ clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
427
+ const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
428
+ const budgetStop = this.budgetStops.get(generation);
429
+ if (budgetStop) this.executionAbort?.abort();
351
430
  this.settledText = typeof event.text === "string" ? event.text : "";
352
- this.settledCancelled = event.cancelled === true;
353
- this.settledError = event.failed ? String(event.error ?? "Pi agent run failed") : "";
431
+ this.settledCancelled = !budgetStop && event.cancelled === true;
432
+ this.settledError = budgetStop ?? (event.failed ? String(event.error ?? "Pi agent run failed") : "");
354
433
  }
355
434
  if (event.type === "agent_settled") {
435
+ if (Number.isSafeInteger(event.generation) && event.generation !== this.budgetGeneration) return;
436
+ clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
356
437
  this.agentRunning = false;
357
438
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
358
- const text = this.settledText.trim(); const error = this.settledError; const cancelled = this.settledCancelled;
439
+ const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
440
+ const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
441
+ this.budgetStops.delete(generation);
359
442
  this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
360
443
  // Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
361
444
  // whole delivery here, steered additions included.
362
445
  const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
363
446
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
447
+ else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
364
448
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
365
449
  else if (text) this.onMessage?.(text, reply);
366
450
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
367
451
  this.activeDeliveryIds.clear();
368
452
  this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
453
+ this.executionAbort = undefined;
369
454
  }
370
455
  }
371
456
 
package/src/cli/launch.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
2
4
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
3
5
 
6
+ /** A scoped candidate MCP server can be selected without promoting the installed plugin. */
7
+ function claudeChannel(userArgs: string[]): string {
8
+ const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
9
+ for (let i = 0; i < userArgs.length; i++) {
10
+ const arg = userArgs[i]!;
11
+ const path = arg === "--mcp-config" ? userArgs[i + 1] : arg.startsWith("--mcp-config=") ? arg.slice("--mcp-config=".length) : undefined;
12
+ if (!path) continue;
13
+ try {
14
+ const server = JSON.parse(readFileSync(resolve(path), "utf8")).mcpServers?.["agent-hub"];
15
+ if (server?.command === "bun" && Array.isArray(server.args) && server.args.some((value: unknown) => typeof value === "string" && resolve(value) === bundle)) return "server:agent-hub";
16
+ } catch { /* The native CLI reports invalid user-supplied MCP configuration. */ }
17
+ }
18
+ return CLAUDE_CHANNEL;
19
+ }
20
+
4
21
  const OWNED: Record<"claude" | "codex", string[]> = {
5
22
  claude: ["--dangerously-load-development-channels", "--dangerously-skip-permissions"],
6
23
  codex: ["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"],
@@ -53,7 +70,7 @@ export function buildLaunch(
53
70
  const notes = [unattended ? UNATTENDED_WARNING : "", ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : ""].filter(Boolean);
54
71
  return {
55
72
  cmd: "claude",
56
- args: ["--dangerously-load-development-channels", CLAUDE_CHANNEL, ...(unattended ? ["--dangerously-skip-permissions"] : []), ...tee, ...passthrough],
73
+ args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...tee, ...passthrough],
57
74
  ...(notes.length ? { warning: notes.join("\n") } : {}),
58
75
  };
59
76
  }
package/src/cli/main.ts CHANGED
@@ -67,6 +67,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
67
67
  ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
68
68
  start the text with [STATUS] to let it batch or [FYI] for the record only
69
69
  ahub pause|resume <peer> hold a peer's deliveries in its queue / release them
70
+ ahub budget execution configure <config.json> | status [id] | disable <id>
70
71
  ahub budget quota windows per peer, and who is paused until when
71
72
  ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week] feed a reading by hand (also: test the relay)
72
73
  ahub budget resume <peer> override a budget pause; readings are ignored for that peer until the window resets
@@ -643,6 +644,18 @@ const commands: Record<string, () => Promise<void> | void> = {
643
644
 
644
645
  budget: async () => {
645
646
  const hub = await connect();
647
+ if (args[0] === "execution") {
648
+ const op = args[1] ?? "status";
649
+ let request: Record<string, unknown> = { t: "execution_budget", op, ...(args[2] ? { id: args[2] } : {}) };
650
+ if (op === "configure") {
651
+ if (!args[2]) { hub.close(); fail("usage: ahub budget execution configure <config.json>"); }
652
+ try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(args[2]!, "utf8")) }; }
653
+ catch { hub.close(); fail("cannot read execution budget JSON configuration"); }
654
+ }
655
+ const result = await hub.request(request); hub.close();
656
+ if (!result.ok) fail(result.error);
657
+ return console.log(JSON.stringify(result.budgets ?? result.budget ?? { disabled: result.disabled }, null, 2));
658
+ }
646
659
  let set: Record<string, unknown> | undefined;
647
660
  if (args[0] === "resume") {
648
661
  if (!args[1]) fail("usage: ahub budget resume <peer>");
@@ -1,6 +1,6 @@
1
1
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
2
2
 
3
- export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
3
+ export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
4
4
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
5
5
 
6
6
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -15,6 +15,7 @@ export function peerLine(id: string, p: PeerRow): string {
15
15
  // Which queued messages the peer will not wait out the batch window for (issue #41).
16
16
  (p.queuedImportant ? ` (${p.queuedImportant} important)` : "") +
17
17
  (p.needsReview ? ` needs review ${p.needsReview}` : "") +
18
+ (p.liveAccepted?.length ? ` awaiting settlement ${p.liveAccepted.join(", ")} (adapter completion; Claude: reply or hub_delivery_done; task state is independent)` : "") +
18
19
  (p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
19
20
  (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
20
21
  (p.attached === false ? " disconnected" : "") +
@@ -114,7 +114,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
114
114
  catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
115
115
  if (source.state === "stopped" || source.state === "missing") continue;
116
116
  const blockers = [...source.blockers];
117
- if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10 or protocol-11 source is required");
117
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11 or protocol-12 source is required");
118
118
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
119
119
  const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
120
120
  for (const peer of source.peers) {
package/src/hub/bus.ts CHANGED
@@ -340,12 +340,14 @@ export class Bus {
340
340
  void this.drain(record.peer);
341
341
  }
342
342
 
343
- queueSummary(peer: string): { needsReview: number; heldBy?: string; oldestQueuedAt?: number } {
343
+ queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
344
344
  const rows = this.storageError ? [] : this.queueList(peer);
345
345
  const queued = this.queues.get(peer) ?? [];
346
346
  const review = rows.filter((r) => r.state === "needs_review");
347
347
  const needsReview = review.length;
348
- const hold = review[0] ? { heldBy: review[0].id } : {};
348
+ const blocked = rows.find((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
349
+ const liveAccepted = rows.filter((r) => r.state === "accepted" && this.activeDeliveries.get(r.id) === peer).map((r) => r.id);
350
+ const hold = { ...(blocked ? { heldBy: blocked.id } : {}), ...(liveAccepted.length ? { liveAccepted } : {}) };
349
351
  const oldest = queued.reduce<number | undefined>((a, r) => a === undefined ? r.ts : Math.min(a, r.ts), undefined);
350
352
  return oldest === undefined ? { needsReview, ...hold } : { needsReview, ...hold, oldestQueuedAt: oldest };
351
353
  }
@@ -379,7 +381,8 @@ export class Bus {
379
381
 
380
382
  private refreshRecoveryHold(peer: PeerId): void {
381
383
  if (!this.journal) return;
382
- const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || r.state === "dispatching" || r.state === "accepted");
384
+ // Live accepted notifications are awaiting explicit correlation, not crash recovery.
385
+ const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
383
386
  if (blocked) this.recoveryHeldPeers.add(peer); else this.recoveryHeldPeers.delete(peer);
384
387
  }
385
388
 
@@ -399,6 +402,17 @@ export class Bus {
399
402
  }
400
403
  }
401
404
 
405
+ /** Explicit completion never resolves recovered, unrelated or already uncertain work. */
406
+ completeDelivery(peer: PeerId, deliveryId: string): boolean {
407
+ const row = this.journal?.get(deliveryId);
408
+ // The bridge separately fences socket/generation/handed IDs. Retrying a completed
409
+ // acknowledgement (or acknowledging after a correlated reply) changes nothing.
410
+ if (row?.peer === peer && row.state === "completed") return true;
411
+ if (this.activeDeliveries.get(deliveryId) !== peer || row?.state !== "accepted") return false;
412
+ this.deliveryReceipt(peer, { id: deliveryId, state: "completed", reason: "explicit correlated completion" });
413
+ return true;
414
+ }
415
+
402
416
  queueIds(id: PeerId): string[] {
403
417
  return (this.queues.get(id) ?? []).map((e) => e.id);
404
418
  }
@@ -6,10 +6,10 @@ 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. The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 11;
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. The plugin is installed apart from the daemon, so they can drift. */
10
+ export const PROTOCOL = 12;
11
11
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
12
- export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, PROTOCOL] as const;
12
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 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. */