@a-t-h-i/bot-lobby 0.6.35 → 0.6.37

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/README.md CHANGED
@@ -36,6 +36,11 @@ you to confirm merging and pushing main, then removes its worktree after verifyi
36
36
  Declining or failed verification keeps the worktree. Delivery can also be reviewed in **Tasks**,
37
37
  where you can create a PR instead. The task branch is kept.
38
38
 
39
+ New tasks get a short title summarizing the full request, which also names their branch and
40
+ session. Naming uses low reasoning on `classifier.effort.cheapModel` when configured, otherwise
41
+ the session model. If naming fails or takes more than eight seconds, the request's first content
42
+ words are used. Tasks started from an agreed plan keep that plan's title.
43
+
39
44
  After every worker step, before QA and at completion, the engine runs the project's own linter
40
45
  (ESLint, Biome, Oxlint, Ruff, or a command you set) on the files the task touched. Only problems on
41
46
  changed lines count, and QA judges any lint suppression a worker adds. **Settings → Linting** sets it
@@ -126,25 +131,33 @@ are kept in `themes.json` beside it, so every Pi session and project shows them.
126
131
  project knowledge stay in `.pi/bot-lobby/` inside your project.
127
132
 
128
133
 
129
- Requires **Pi 1.0.x** (1.0.1 or later). Every bot-lobby agent can use
130
- `delegate_subtasks` for substantial, separable work. Each parent gets five child
131
- slots total, shared across retries; children inherit its model, thinking, role,
134
+ Requires **Pi 1.0.x** (1.0.1 or later). DEV and DESIGN workers and QA reviewers can use
135
+ `delegate_subtasks` for useful, separable work. Delegation is optional. Each parent gets five child
136
+ slots total, shared across retries; Jev selects each child’s model and reasoning effort from its brief, lowering
137
+ inference for simple work and raising reasoning for complex work on the configured
138
+ model. Children inherit the parent’s role,
132
139
  permissions, worktree and remaining time. Children report to their parent, which
133
140
  integrates and verifies the result. Children cannot delegate further. Active
134
141
  children share `workflow.maxParallelWorkers`; waiting parents do not use those
135
142
  child slots. Cancelling or finishing a parent stops its children and releases
136
143
  their file claims. Full reports are saved under `.pi/bot-lobby/delegations/`.
137
144
 
138
- Active task steps show the parent and active worker children, for example
145
+ Every assignment has a 15-minute total allowance (or its shorter configured limit),
146
+ including startup, tools, retries, children, compaction and reporting. Per-step
147
+ allowances persist across reassignments; only the user can extend them, including
148
+ in auto mode. Waiting for user answers pauses clocks.
149
+
150
+ The conversation pane groups running agents beneath their current step, for example
139
151
  **Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
140
152
  are excluded. A child finishing does not complete a checklist step.
141
153
 
142
154
  The oracle, agents and children compact at settled turn boundaries before context
143
- reaches 250,000 estimated tokens, or earlier at 80% of the model window or Pi's
155
+ reaches 64,000 estimated tokens, or earlier at 60% of the model window or Pi's
144
156
  native reserve threshold. Recent messages and summaries retain unfinished work;
145
157
  raw session history remains available. A failed summary leaves context intact.
146
- The boundary gives headroom below 300,000 tokens; a single very large input or tool
147
- result can temporarily exceed it before the next boundary.
158
+ Checkpoints preserve constraints, decisions, changed files, verification, failed
159
+ approaches, and next actions. A single large input can exceed the threshold before
160
+ the next settled boundary.
148
161
 
149
162
  Pi's native MCP, `tool_search` and `codemode` are available. **MCP tools** in each
150
163
  agent's Settings entry grants exact tool names, one per line, such as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/prompts/master.md CHANGED
@@ -282,6 +282,13 @@ Every delegation costs a full agent run, so keep the loop short:
282
282
 
283
283
  ## Time budget
284
284
 
285
+ Every assignment has at most 15 minutes total, or its shorter configured limit,
286
+ including retries, children, tools, compaction and reporting. Step allowances
287
+ persist across reassignments: name the plan step explicitly, and do not reword
288
+ or split an exhausted step to reset its clock. Only the user can extend it,
289
+ including in auto mode. Return an unfinished report when time is spent; stop
290
+ repeating failed approaches. Waiting for the user pauses clocks.
291
+
285
292
  When the task has a time budget (your context says how much is used and
286
293
  left), it covers everyone: you, scouts, workers and the QA gate. The clock
287
294
  runs while you work and stops while you wait on the user.
@@ -30,9 +30,9 @@ export interface RunProfileLike {
30
30
  }
31
31
 
32
32
  export interface EffortRoute extends RunProfileLike {
33
- level: "trivial" | "simple";
33
+ level: EffortLevel;
34
34
  confidence: number;
35
- /** The configured profile the route came down from. */
35
+ /** The configured profile before routing. */
36
36
  from: RunProfileLike;
37
37
  }
38
38
 
@@ -109,6 +109,8 @@ export interface EffortScore {
109
109
 
110
110
  /** Lowers the model or thinking of a step the classifier judges simple or trivial. */
111
111
  export interface EffortRouter {
112
+ /** Children may raise reasoning for complex work, using the parent's configured model. */
113
+ childRoute?(instruction: string, profile: RunProfileLike, options?: { context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
112
114
  /** Score a step and plan its route in one go. */
113
115
  route(instruction: string, profile: RunProfileLike, options?: { thinkingFixed?: boolean; context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
114
116
  /** Score a step once (several agents may share it)… */
@@ -119,6 +121,16 @@ export interface EffortRouter {
119
121
 
120
122
  export function effortRouter(classifier: Classifier, deps: { clamp?: RouteOptions["clamp"]; log?: (text: string) => void } = {}): EffortRouter {
121
123
  const router: EffortRouter = {
124
+ async childRoute(instruction, profile, options = {}) {
125
+ const scored = await router.score(instruction, options);
126
+ if (!scored) return undefined;
127
+ if (scored.level !== "complex" || scored.confidence < classifier.config.thresholds.simpleAt) return router.plan(scored, profile);
128
+ const thinking = profile.model && deps.clamp ? deps.clamp(profile.model, "max") : rank(profile.thinking) > rank("high") ? profile.thinking : "high";
129
+ if (thinking === profile.thinking) return undefined;
130
+ const route: EffortRoute = { ...profile, thinking, level: scored.level, confidence: scored.confidence, from: { ...profile } };
131
+ deps.log?.(`routed child ${routeLabel(route)}`);
132
+ return route;
133
+ },
122
134
  async score(instruction, options = {}) {
123
135
  if (!classifier.enabled("effort")) return undefined;
124
136
  return scoreEffort(classifier, instruction, options.context, options.signal);
@@ -8,6 +8,8 @@ import { EditLog } from "../state/changes.ts";
8
8
  import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
9
  import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
10
  import { ASK_ENV } from "../ask/relay.ts";
11
+ import { STEP_LIMIT_MS } from "../state/step-limits.ts";
12
+ import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
11
13
  import { ASK_TOOL } from "../ask/types.ts";
12
14
  import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
13
15
 
@@ -22,7 +24,7 @@ export interface AgentContext {
22
24
  }
23
25
 
24
26
  /** Live fields a caller (the file desk) may set on a running agent. */
25
- export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor">;
27
+ export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor" | "model" | "thinking" | "route" | "routedFrom">;
26
28
 
27
29
  /** Control over one running attempt, handed to `onStart`. */
28
30
  export interface AgentHandle {
@@ -35,6 +37,8 @@ export interface AgentRequest {
35
37
  taskId: string;
36
38
  domain: Domain;
37
39
  role: Role;
40
+ effort?: EffortRouter;
41
+ effortProfile?: RunProfileLike;
38
42
  /** Concrete instruction sent as the subagent's task message. */
39
43
  instruction: string;
40
44
  /** Prompt layers selected for this run. */
@@ -78,6 +82,10 @@ export interface AgentRequest {
78
82
  * (the engine asking the user) grants more, and the same agent carries on.
79
83
  */
80
84
  export interface AgentTime {
85
+ /** Active time shared across attempts and model fallbacks. */
86
+ usedMs?: number;
87
+ /** Include reporting in the hard allowance. */
88
+ strict?: boolean;
81
89
  /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
82
90
  endsAt: number;
83
91
  /** The allotment, as the agent and the lobby see it. */
@@ -108,6 +116,11 @@ export function carryOnMessage(extraMs: number): string {
108
116
  /** Least time an attempt starts with; a retry with less than this left is not started. */
109
117
  const MIN_ATTEMPT_MS = 60_000;
110
118
 
119
+ export function boundedAgentTime(timeoutMs: number): AgentTime {
120
+ const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
121
+ return { endsAt: Date.now() + allotMs, allotMs, strict: true };
122
+ }
123
+
111
124
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
112
125
  export function watchdogOptions(workflow: {
113
126
  maxAgentRetries: number;
@@ -137,7 +150,19 @@ function retryable(run: AgentRun): boolean {
137
150
  * and a slow agent cannot double its time budget.
138
151
  */
139
152
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
153
+ request.time ??= boundedAgentTime(request.timeoutMs);
154
+ if (!request.time.strict) {
155
+ request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
156
+ request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
157
+ request.time.strict = true;
158
+ }
159
+ request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
140
160
  const startedAt = new Date().toISOString();
161
+ if (request.time.endsAt <= Date.now()) {
162
+ const stopped: AgentRun = { ...baseRun(request, `${request.taskId}:${randomUUID()}`, startedAt), status: "timeout", error: "The assignment's shared time limit is spent; request user-approved time before continuing.", finishedAt: startedAt, timeUp: true };
163
+ request.onUpdate?.(stopped);
164
+ return stopped;
165
+ }
141
166
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
142
167
  // A failed attempt may have edited files before the retry: the run owns every edit.
143
168
  const edits = new EditLog(request.cwd);
@@ -193,21 +218,36 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
193
218
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
194
219
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
195
220
  request.onUpdate?.(base);
196
- const live = createLiveRun(base, request, edits);
221
+ const time = request.time!;
222
+ const attemptStart = Date.now();
223
+ let pausedMs = 0;
224
+ let asking = 0;
225
+ let askStarted = 0;
226
+ let expired = false;
227
+ const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
228
+ const live = createLiveRun(base, { ...request, onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: (time.usedMs ?? 0) + elapsed(), endsAt: time.endsAt }) }, edits);
197
229
 
198
230
  const controller = new AbortController();
199
231
  activeControllers.add(controller);
200
232
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
233
+ let deadline: ReturnType<typeof setTimeout>;
234
+ const arm = () => {
235
+ clearTimeout(deadline);
236
+ deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
237
+ };
238
+ arm();
201
239
  try {
202
240
  const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
203
- const time = request.time;
204
- const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
205
- const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
241
+ const left = Math.max(1, time.endsAt - Date.now());
242
+ const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
243
+ const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
206
244
  const onTimeUp = time?.onTimeUp;
207
245
  const result = await runPiAgent({
208
246
  cwd: request.cwd,
209
247
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
210
248
  delegationQuota: quota,
249
+ effort: request.effort,
250
+ effortProfile: request.effortProfile,
211
251
  onChildRun: (child) => {
212
252
  for (const path of child.edited ?? []) edits.note("edit", { path });
213
253
  request.onUpdate?.(child);
@@ -218,21 +258,28 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
218
258
  tools: toolsFor(request),
219
259
  model: request.model,
220
260
  thinking: request.thinking,
221
- timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
261
+ timeoutMs: left,
222
262
  ...(time ? {
223
263
  time: {
224
- upAtMs: left,
264
+ hardLimit: true,
265
+ upAtMs: Math.max(1, left - reportMs),
225
266
  headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
226
267
  headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
227
- upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
228
- graceMs: REPORT_GRACE_MS,
268
+ upMessage: `bot-lobby: your shared step allowance is nearly spent. Start nothing new; finish or revert the edit in progress and report completed work, verification, failed approaches, blockers, and the next action now.${onTimeUp ? " Include ## Left Off and ## More Time (minutes and reason). Only the user can grant more time." : ""}`,
269
+ graceMs: reportMs,
229
270
  ...(onTimeUp ? {
230
271
  onTimeUp: async (report: string) => {
231
- const extraMs = await onTimeUp(live.current(), report);
272
+ clearTimeout(deadline);
273
+ const waiting = Date.now();
274
+ let extraMs: number;
275
+ try { extraMs = await onTimeUp(live.current(), report); }
276
+ finally { pausedMs += Date.now() - waiting; }
277
+ time.endsAt += Date.now() - waiting;
232
278
  if (extraMs > 0) {
233
279
  time.endsAt = Date.now() + extraMs;
234
280
  live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
235
281
  }
282
+ arm();
236
283
  return { extraMs, message: carryOnMessage(extraMs) };
237
284
  },
238
285
  } : {}),
@@ -245,30 +292,42 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
245
292
  stallTimeoutMs: request.stallTimeoutMs,
246
293
  toolStallTimeoutMs: request.toolStallTimeoutMs,
247
294
  wrapUpAtMs: wrapUp,
248
- onEvent: live.onEvent,
295
+ onEvent: (event) => {
296
+ if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; clearTimeout(deadline); }
297
+ if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
298
+ live.onEvent(event);
299
+ if (event.type === "answered" && asking === 0) arm();
300
+ },
249
301
  onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
250
302
  }, run);
251
303
  const current = live.current();
252
304
  const final: AgentRun = {
253
305
  ...base,
254
- status: result.status,
306
+ status: expired ? "timeout" : result.status,
255
307
  output: result.output,
256
- error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
308
+ error: expired ? describeError("hit the shared assignment time limit", current, false) : result.error ? describeError(result.error, current, result.stalled === true) : undefined,
257
309
  usage: result.usage,
258
310
  finishedAt: new Date().toISOString(),
311
+ activeMs: (time.usedMs ?? 0) + elapsed(),
259
312
  turns: current.turns ?? (result.usage.turns || undefined),
260
313
  tools: current.tools,
261
314
  model: result.model ?? current.model,
262
315
  ...(result.stalled ? { stalled: true } : {}),
263
316
  ...(result.wrappedUp ? { wrappedUp: true } : {}),
264
- ...(result.timeUp ? { timeUp: true } : {}),
317
+ ...(result.timeUp || expired ? { timeUp: true } : {}),
265
318
  ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
266
319
  note: undefined,
267
320
  noteKind: undefined,
268
321
  };
269
322
  request.onUpdate?.(final);
270
323
  return final;
324
+ } catch (error) {
325
+ const final: AgentRun = { ...live.current(), status: expired ? "timeout" : signal.aborted ? "cancelled" : "failed", error: (error as Error).message, activeMs: (time.usedMs ?? 0) + elapsed(), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined, ...(expired ? { timeUp: true } : {}) };
326
+ request.onUpdate?.(final);
327
+ return final;
271
328
  } finally {
329
+ clearTimeout(deadline!);
330
+ time.usedMs = (time.usedMs ?? 0) + elapsed();
272
331
  activeControllers.delete(controller);
273
332
  }
274
333
  }
@@ -5,6 +5,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
7
7
  import type { AgentRun } from "../schemas/findings.ts";
8
+ import type { Domain, Role } from "../schemas/agent.ts";
9
+ import { effortFor } from "../classifier/instance.ts";
10
+ import { fellShort, profileLabel, routeLabel } from "../classifier/effort.ts";
8
11
  import { agentProfile } from "../schemas/configuration.ts";
9
12
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
10
13
  import { EditLog } from "../state/changes.ts";
@@ -18,6 +21,13 @@ export const DELEGATE_TOOL = "delegate_subtasks";
18
21
  export const AGENT_ENV = "BOT_LOBBY_AGENT_POLICY";
19
22
  export const DELEGATION_ENV = "BOT_LOBBY_DELEGATION";
20
23
 
24
+ /** Only DESIGN/DEV workers and the QA gate may spawn children. */
25
+ export function canDelegate(agent: { role: Role; domain: Domain; depth?: number }, quotaUsed: number): boolean {
26
+ if ((agent.depth ?? 1) !== 1 || quotaUsed >= MAX_CHILDREN) return false;
27
+ if (agent.role === "worker") return agent.domain === "designer" || agent.domain === "backend";
28
+ return agent.role === "reviewer" && agent.domain === "qa";
29
+ }
30
+
21
31
  /** One shared child pool per task; parents waiting on children never hold its slots. */
22
32
  const pools = new Map<string, { active: number; users: number; waiters: Array<() => void> }>();
23
33
 
@@ -63,7 +73,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
63
73
  const agent = options.agent ?? { runId: id, taskId: id, domain: "backend" as const, role: editable ? "worker" as const : "reviewer" as const };
64
74
  const depth = agent.depth ?? 1;
65
75
  const quota = options.delegationQuota ?? { used: 0 };
66
- const tools = [...new Set([...baseTools, ...(options.excalidraw ? toolsOf(options.excalidraw) : []), "codemode", "tool_search", ...(depth === 1 ? [DELEGATE_TOOL] : [])])];
76
+ const tools = [...new Set([...baseTools, ...(options.excalidraw ? toolsOf(options.excalidraw) : []), "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
67
77
  const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
68
78
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
69
79
  if (depth > 1) return { tools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
@@ -100,17 +110,26 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
100
110
 
101
111
  const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
102
112
  const childId = randomUUID();
103
- const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
113
+ const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", endsAt: deadlineAt, output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
104
114
  const edits = new EditLog(options.cwd);
105
115
  const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
106
116
  update(base);
107
117
  try {
108
- const left = deadlineAt - Date.now();
109
- if (left <= 0) throw new Error("The parent's time is up.");
110
- const result = await runPiAgent({
118
+ const configured = options.effortProfile ?? { model: options.model, thinking: options.thinking ?? "medium" };
119
+ const effort = options.effort ?? effortFor();
120
+ const route = await effort.childRoute?.(brief, configured, { context: options.task, signal }).catch(() => undefined);
121
+ const attempt = async (model: string | undefined, thinking: string) => {
122
+ const left = deadlineAt - Date.now();
123
+ signal.throwIfAborted();
124
+ if (left <= 0) throw new Error("The parent's time is up.");
125
+ const routed = route && model === route.model && thinking === route.thinking;
126
+ live.annotate({ model, thinking, route: routed ? routeLabel(route) : undefined, routedFrom: routed ? profileLabel(route.from) : undefined, note: routed ? routeLabel(route) : undefined, noteKind: "info" });
127
+ return runPiAgent({
111
128
  ...options,
112
129
  agent: { ...agent, runId: childId, depth: 2 },
113
130
  task: brief,
131
+ model,
132
+ thinking,
114
133
  tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
115
134
  mcpTools,
116
135
  timeoutMs: left,
@@ -123,11 +142,18 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
123
142
  env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
124
143
  onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
125
144
  }, run);
126
- const final: AgentRun = { ...live.current(), ...result, edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
145
+ };
146
+ let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
147
+ let retried = false;
148
+ if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted && deadlineAt - Date.now() >= 60_000) {
149
+ retried = true;
150
+ result = await attempt(configured.model, configured.thinking);
151
+ }
152
+ const final: AgentRun = { ...live.current(), ...result, thinking: retried ? configured.thinking : route?.thinking ?? configured.thinking, attempts: retried ? 2 : 1, ...(route && !retried ? { route: routeLabel(route), routedFrom: profileLabel(route.from) } : {}), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
127
153
  update(final);
128
154
  return final;
129
155
  } catch (error) {
130
- const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString() };
156
+ const final: AgentRun = { ...live.current(), edited: edits.list(), status: Date.now() >= deadlineAt ? "timeout" : signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
131
157
  update(final);
132
158
  return final;
133
159
  } finally {
@@ -165,7 +191,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
165
191
  return {
166
192
  tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
167
193
  env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
168
- note: `For substantial, separable work, use ${DELEGATE_TOOL} with complete briefs (goal, files, contracts, constraints, done criteria). You have at most five children total; children cannot delegate. Integrate and verify their work yourself. Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
194
+ note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
169
195
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
170
196
  desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
171
197
  options.onStart?.(handle);
@@ -11,6 +11,7 @@ import { shortDuration } from "../text.ts";
11
11
  import { readRelayRequest } from "../ask/relay.ts";
12
12
  import type { AskQuestion, AskResult } from "../ask/types.ts";
13
13
  import { grantEnv, grantNote, type Grant } from "../excalidraw/sessions.ts";
14
+ import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
14
15
 
15
16
  /** Live control over one running subagent: queue a steering message for its next turn. */
16
17
  export interface RunHandle {
@@ -24,6 +25,8 @@ export interface RunHandle {
24
25
  export type RelayAsk = (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>;
25
26
 
26
27
  export interface PiRunOptions {
28
+ effort?: EffortRouter;
29
+ effortProfile?: RunProfileLike;
27
30
  agent?: { runId: string; taskId: string; domain: Domain; role: Role; depth?: number };
28
31
  mcpTools?: string[];
29
32
  delegationQuota?: { used: number };
@@ -67,6 +70,8 @@ export interface PiRunOptions {
67
70
  * the decision is pending, and move out with any time granted.
68
71
  */
69
72
  export interface TimeLimit {
73
+ /** Reporting time is included in the allowance, including extensions. */
74
+ hardLimit?: boolean;
70
75
  upAtMs: number;
71
76
  headsUpAtMs?: number;
72
77
  headsUpMessage?: string;
@@ -584,11 +589,12 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
584
589
  turnDone = false;
585
590
  clockStart = Date.now();
586
591
  lastOutput = clockStart;
587
- upAtMs = extraMs;
592
+ upAtMs = time?.hardLimit ? Math.max(1, extraMs - Math.min(time.graceMs, Math.floor(extraMs / 4))) : extraMs;
588
593
  // The user just named the time it has: no heads-up quoting the old allotment.
589
594
  headsUpAtMs = 0;
590
- deadlineAt = Date.now() + extraMs + (time?.graceMs ?? 0);
591
- deadline = setTimeout(onDeadline, extraMs + (time?.graceMs ?? 0));
595
+ const nextLimit = extraMs + (time?.hardLimit ? 0 : time?.graceMs ?? 0);
596
+ deadlineAt = Date.now() + nextLimit;
597
+ deadline = setTimeout(onDeadline, nextLimit);
592
598
  input.send({ type: "prompt", message: message ?? "You have more time: carry on from where you left off and finish." });
593
599
  options.onEvent?.({ type: "extended", ms: extraMs });
594
600
  }, () => {
@@ -8,6 +8,7 @@ import type { AgentRun } from "../schemas/findings.ts";
8
8
  */
9
9
  import { loadPrompt } from "../prompts/loader.ts";
10
10
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
11
+ import { MAX_CHILDREN } from "../execution/delegation.ts";
11
12
  import { grantOption } from "../excalidraw/sessions.ts";
12
13
  import { describeToolCall } from "../pi/activity.ts";
13
14
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
@@ -298,7 +299,7 @@ export class QuickFixQueue {
298
299
  this.edits.set(job.id, new EditLog(this.deps.cwd));
299
300
  try {
300
301
  const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
301
- const quota = { used: 0 };
302
+ const quota = { used: MAX_CHILDREN };
302
303
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
303
304
  {
304
305
  cwd: this.deps.cwd,
@@ -7,7 +7,7 @@ import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding
7
7
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
8
8
  import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
9
  import { randomUUID } from "node:crypto";
10
- import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
10
+ import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
11
11
  import { taskWork } from "../state/work-time.ts";
12
12
  import { refreshReview, deferReview } from "../delivery/review.ts";
13
13
  import { deliver, type DeliveryRequest } from "../delivery/operations.ts";
@@ -221,6 +221,7 @@ function sessionsChanged(): void {
221
221
  const seen = announced.get(session.key) ?? 0;
222
222
  announced.set(session.key, session.dialogs.length);
223
223
  if (!state) continue;
224
+ syncTaskName(state, session);
224
225
  if (session.dialogs.length > seen) lobbyNotify(state, `bot-lobby: ${session.name} is waiting for you — see the Sessions page`, "info");
225
226
  if (!session.alive && !exitsSaid.has(session.key)) {
226
227
  exitsSaid.add(session.key);
@@ -229,6 +230,12 @@ function sessionsChanged(): void {
229
230
  }
230
231
  }
231
232
 
233
+ function syncTaskName(state: Runtime, session: BackgroundSession): void {
234
+ if (!session.sessionId) return;
235
+ const task = peekOwnedTask(state.root, state.configDir, session.sessionId);
236
+ if (task) session.name = task.id;
237
+ }
238
+
232
239
  function dialogTracker(state: Runtime): (id: string, pending: boolean, sessionId?: string) => void {
233
240
  const requests = new Map<string, { taskId: string; sessionId: string; requestId: string }>();
234
241
  const prefix = `dialog:${randomUUID()}`;
@@ -64,7 +64,7 @@ export const STOP_GRACE_MS = 5000;
64
64
  export class BackgroundSession {
65
65
  readonly key: string;
66
66
  readonly projectRoot?: string;
67
- readonly name: string;
67
+ name: string;
68
68
  /** The planned task it was started from. */
69
69
  readonly planId?: string;
70
70
  /** The task it was started to carry on. */
@@ -4,7 +4,7 @@ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schema
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
7
+ import { boundedAgentTime, runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { knowledgeFilePath, readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
@@ -52,7 +52,7 @@ export interface ScoutRequest {
52
52
  /** Model, thinking and time limit for one run, from settings. */
53
53
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
54
54
  const profile = profileFor(config, resolver, domain, role);
55
- return { model: profile.model, mcpTools: profile.mcpTools, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
55
+ return { model: profile.model, mcpTools: profile.mcpTools, thinking: profile.thinking, timeoutMs: profile.timeoutMs, time: boundedAgentTime(profile.timeoutMs) };
56
56
  }
57
57
 
58
58
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -142,6 +142,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
142
142
  const extraTools = request.hints?.tools() ?? [];
143
143
  const requests: AgentRequest[] = request.domains.map((domain, index) => ({
144
144
  delegationQuota: { used: 0 },
145
+ effort: request.effort,
145
146
  taskId: request.taskId,
146
147
  domain,
147
148
  role: "scout",
@@ -161,7 +162,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
161
162
  const runs = await runParallel(first, request.config.workflow.maxParallelScouts, run);
162
163
  let outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
163
164
  // A routed scout that came back unusable runs again on its configured model.
164
- const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted ? index : -1)).filter((index) => index >= 0);
165
+ const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted && requests[index]!.time!.endsAt > Date.now() ? index : -1)).filter((index) => index >= 0);
165
166
  if (again.length > 0) {
166
167
  const reruns = await runParallel(again.map((index) => requests[index]!), request.config.workflow.maxParallelScouts, run);
167
168
  outcomes = outcomes.map((outcome, index) => {
@@ -288,6 +289,8 @@ export async function runWorker(
288
289
  const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools];
289
290
  const base: AgentRequest = {
290
291
  delegationQuota: { used: 0 },
292
+ effort: request.effort,
293
+ effortProfile: configured,
291
294
  taskId: request.taskId,
292
295
  domain: request.domain,
293
296
  role: "worker",
@@ -311,7 +314,7 @@ export async function runWorker(
311
314
  };
312
315
  let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
313
316
  // A routed step that fell short runs again at the configured model and thinking.
314
- if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
317
+ if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted && base.time!.endsAt > Date.now()) {
315
318
  const routed = outcome.run.edited ?? [];
316
319
  outcome = workerOutcome(request.domain, await runAgent(base, run));
317
320
  // The step owns what the routed attempt edited too.
@@ -337,6 +340,7 @@ export interface ReviewerOutcome {
337
340
  }
338
341
 
339
342
  export interface ReviewerRequest {
343
+ effort?: EffortRouter;
340
344
  taskId: string;
341
345
  domain: Domain;
342
346
  taskText: string;
@@ -400,6 +404,7 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
400
404
  taskId: request.taskId,
401
405
  domain: request.domain,
402
406
  role: "reviewer",
407
+ effort: request.effort,
403
408
  instruction:
404
409
  request.instruction?.trim() ||
405
410
  "Review the current repository changes against the approved requirements and plan.",
@@ -434,7 +439,7 @@ export async function runReviewer(
434
439
  const agentRequest = reviewerAgentRequest(request, selected);
435
440
  const attempts = Math.max(1, request.config.workflow.maxAgentRetries + 1);
436
441
  let agentRun = await runAgent(agentRequest, run);
437
- for (let attempt = 2; attempt <= attempts && isRetryableReviewRun(agentRun); attempt++) {
442
+ for (let attempt = 2; attempt <= attempts && isRetryableReviewRun(agentRun) && agentRequest.time!.endsAt > Date.now(); attempt++) {
438
443
  agentRun = await runAgent(agentRequest, run);
439
444
  }
440
445
  const result = parseReviewResult(request.domain, agentRun.output);
@@ -2,10 +2,10 @@ import { compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, typ
2
2
  import { contextMarks, freshContextOn } from "./fresh-context.ts";
3
3
  import { isSubagentProcess } from "./quiet.ts";
4
4
 
5
- export const CONTEXT_TARGET = 250_000;
5
+ export const CONTEXT_TARGET = 64_000;
6
6
 
7
7
  export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens): number {
8
- return Math.max(1, Math.min(CONTEXT_TARGET, Math.floor(window * 0.8), window - Math.min(reserve, Math.floor(window / 2))));
8
+ return Math.max(1, Math.min(CONTEXT_TARGET, Math.floor(window * 0.6), window - Math.min(reserve, Math.floor(window / 2))));
9
9
  }
10
10
 
11
11
  /** Compact at a settled tool boundary; ctx.compact() would abort the turn we need to continue. */
@@ -30,7 +30,7 @@ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compa
30
30
  if (boundary !== undefined) entries = entries.filter((entry) => Date.parse(entry.timestamp) >= boundary);
31
31
  }
32
32
  if (entries.length < 2) return;
33
- const keepRecentTokens = Math.min(override?.keepRecentTokens ?? configured?.keepRecentTokens ?? DEFAULT_COMPACTION_SETTINGS.keepRecentTokens, Math.floor(threshold / 4));
33
+ const keepRecentTokens = Math.min(16_000, override?.keepRecentTokens ?? configured?.keepRecentTokens ?? DEFAULT_COMPACTION_SETTINGS.keepRecentTokens, Math.floor(threshold / 4));
34
34
  const cut = findCutPoint(entries, 0, entries.length, keepRecentTokens);
35
35
  if (cut.firstKeptEntryIndex <= 0) return;
36
36
  const prefix = entries.slice(0, cut.firstKeptEntryIndex);
@@ -61,7 +61,7 @@ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compa
61
61
  const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
62
62
  const retry = pi.getSettings().retry;
63
63
  const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
64
- const result = await summarize({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model, auth.apiKey, headers, "Preserve goals, constraints, decisions, file references, verification evidence, unfinished work and outstanding delegated subtasks. The parent remains responsible for integration and verification.", ctx.signal, undefined, undefined, auth.env, retryPolicy);
64
+ const result = await summarize({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model, auth.apiKey, headers, "Write a concise checkpoint with: Requirements and constraints; Decisions; Files changed; Verification and evidence; Failed approaches (do not repeat); Unfinished work and next action; Outstanding children and their assignments. Preserve concrete file references and distinguish verified facts from guesses. The parent remains responsible for integration and verification.", ctx.signal, undefined, undefined, auth.env, retryPolicy);
65
65
  ctx.signal?.throwIfAborted();
66
66
  ctx.ui.notify("Agent context compacted", "info");
67
67
  return { entries: [...event.entries, { type: "compaction" as const, summary: result.summary, firstKeptEntryId, usage: result.usage, details: result.details }] };