@a-t-h-i/bot-lobby 0.6.54 → 0.6.55

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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/prompts/master.md +10 -30
  4. package/prompts/worker.md +8 -12
  5. package/src/desk/ipc.ts +4 -3
  6. package/src/execution/agent-runner.ts +16 -129
  7. package/src/execution/delegation.ts +10 -32
  8. package/src/execution/live-run.ts +3 -14
  9. package/src/execution/pi-runner.ts +27 -162
  10. package/src/index.ts +3 -0
  11. package/src/lobby/feed.ts +1 -1
  12. package/src/lobby/host.ts +2 -0
  13. package/src/lobby/planner.ts +2 -2
  14. package/src/lobby/pr-review.ts +3 -3
  15. package/src/lobby/quickfix.ts +4 -3
  16. package/src/lobby/recovery.ts +2 -10
  17. package/src/lobby/runtime.ts +1 -1
  18. package/src/lobby/service.ts +3 -1
  19. package/src/master/goal-check.ts +1 -6
  20. package/src/master/master.ts +10 -22
  21. package/src/master/research.ts +3 -6
  22. package/src/pi/commands.ts +5 -45
  23. package/src/pi/delegation.ts +4 -4
  24. package/src/pi/events.ts +6 -19
  25. package/src/pi/grants.ts +89 -0
  26. package/src/pi/model-settings.ts +2 -4
  27. package/src/pi/model-support.ts +6 -6
  28. package/src/pi/route.ts +1 -1
  29. package/src/pi/run-summary.ts +3 -7
  30. package/src/pi/start-flags.ts +3 -3
  31. package/src/pi/start-task.ts +2 -8
  32. package/src/pi/tools.ts +8 -11
  33. package/src/roles/worker.ts +0 -18
  34. package/src/schemas/configuration.ts +47 -39
  35. package/src/schemas/findings.ts +0 -12
  36. package/src/schemas/task.ts +0 -1
  37. package/src/state/metrics.ts +1 -1
  38. package/src/state/work-clock.ts +140 -0
  39. package/src/state/work-time.ts +4 -7
  40. package/src/webui/api/index.ts +2 -1
  41. package/src/webui/api/settings.ts +6 -18
  42. package/src/webui/dev/fake-service.ts +14 -0
  43. package/src/webui/protocol.ts +23 -0
  44. package/src/workflow/workflow.ts +23 -318
  45. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
  46. package/webui/dist/assets/index-BkXhtB6A.css +2 -0
  47. package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
  48. package/webui/dist/build.json +10 -6
  49. package/webui/dist/index.html +2 -2
  50. package/src/state/budget.ts +0 -301
  51. package/src/state/step-limits.ts +0 -41
  52. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  53. package/webui/dist/assets/index-DsVdXmh5.css +0 -2
package/README.md CHANGED
@@ -73,4 +73,4 @@ Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves the
73
73
  project knowledge stay in `.pi/bot-lobby/` inside your project.
74
74
 
75
75
  The [guide](docs/guide.md) covers the rest: delivery and commits, crash recovery, knowledge and
76
- shared scripts, the classifier, delegation, time limits, compaction and MCP tools.
76
+ shared scripts, the classifier, delegation, compaction, and MCP tools and skills.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.54",
3
+ "version": "0.6.55",
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
@@ -339,36 +339,16 @@ agents share facts without you:
339
339
  `reports/` folder and the report gives its path; long findings go in the
340
340
  task's `notes/` folder. Read the file when the detail matters rather than
341
341
  asking for it again.
342
- - A report flagged as wrapped up early or timed out may be partial: check what
343
- is missing and delegate only the remainder.
344
-
345
- ## Time budget
346
-
347
- Every assignment has at most 15 minutes total, or its shorter configured limit,
348
- including retries, children, tools, compaction and reporting. Step allowances
349
- persist across reassignments: name the plan step explicitly, and do not reword
350
- or split an exhausted step to reset its clock. Only the user can extend it,
351
- including in auto mode. Return an unfinished report when time is spent; stop
352
- repeating failed approaches. Waiting for the user pauses clocks.
353
-
354
- When the task has a time budget (your context says how much is used and
355
- left), it covers everyone: you, scouts, workers and the QA gate. The clock
356
- runs while you work and stops while you wait on the user.
357
-
358
- - Size the plan to fit it, and say so in the proposal when it does not.
359
- - Divide what is left by scope: give each `implement` its `minutes` (per
360
- assignment in a parallel batch). Bigger steps get more; keep the QA gate's
361
- reserve (the engine holds it back). Without `minutes` a step gets an even
362
- share.
363
- - Every agent is told its minutes. One that runs out stops, reports what it
364
- did, where it left off and how much more it needs, and the user decides; if
365
- they give it more, the same agent carries on where it stopped. A step that
366
- was not given more comes back unfinished: trim the scope, or ask for task
367
- time.
368
- - When the budget is spent the engine starts no new work. Ask the user with
369
- `action=budget` (`minutes` and a `reason`: what is left and why it is worth
370
- it), or wrap up with what is done. `action=budget` with no minutes shows
371
- where it stands.
342
+ - A report flagged as stalled may be partial: check what is missing and
343
+ delegate only the remainder.
344
+
345
+ ## No time limits
346
+
347
+ Agents have no time limit. An assignment runs until its agent reports, the
348
+ user stops it, or it goes silent so long that the stall watchdog stops it.
349
+ Size steps by scope, not by the clock, and never cut a step short or split it
350
+ to save time. In auto mode keep going until the task is complete or truly
351
+ blocked: nobody needs to grant more time.
372
352
 
373
353
  ## Git branch or worktree
374
354
 
package/prompts/worker.md CHANGED
@@ -58,18 +58,14 @@ no code deleted or bent just to make a rule pass. Every one of them is listed
58
58
  for QA, who reads it as a finding unless it carries a reason that holds. When
59
59
  a rule is wrong for your change, leave it failing and say why under Notes.
60
60
 
61
- ## Time budget
62
-
63
- Work efficiently: read what you need, make the change, verify, report. If the
64
- engine asks you to wrap up, stop exploring, leave every file consistent, and
65
- write your report with anything unfinished under Blockers or Notes.
66
-
67
- When your context has a **Time** section, the step has that many minutes. Land
68
- the most important part first and keep files consistent as you go. When the
69
- time is up you are told to stop: finish or revert the edit in progress, then
70
- report with `## Left Off` (what you were doing, what is still to do) and
71
- `## More Time` (`N minutes — why`), honestly sized. If the user gives you more,
72
- you carry on from where you stopped.
61
+ ## Pace
62
+
63
+ There is no time limit: finish the step properly and verify it rather than
64
+ cutting it short. Work efficiently all the same: read what you need, make the
65
+ change, verify, report, and do not explore past what the step needs. Keep
66
+ every file consistent as you go. A long silent command (one that prints
67
+ nothing for many minutes) can be stopped as stalled, so give such commands
68
+ output or a bash `timeout`.
73
69
 
74
70
  ## Working alongside other workers (file desk)
75
71
 
package/src/desk/ipc.ts CHANGED
@@ -132,7 +132,7 @@ export interface DeskClient {
132
132
  export function createDeskClient(address: string, worker: string): DeskClient {
133
133
  let socket: Socket | undefined;
134
134
  let nextId = 1;
135
- const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> }>();
135
+ const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> | undefined }>();
136
136
  const failAll = (text: string) => {
137
137
  for (const [id, entry] of pending) {
138
138
  clearTimeout(entry.timer);
@@ -170,10 +170,11 @@ export function createDeskClient(address: string, worker: string): DeskClient {
170
170
  request(request, timeoutMs = 10_000) {
171
171
  const id = nextId++;
172
172
  return new Promise((done) => {
173
- const timer = setTimeout(() => {
173
+ // 0 waits for as long as the answer takes (a delegation, a consult): no time limit.
174
+ const timer = timeoutMs > 0 ? setTimeout(() => {
174
175
  pending.delete(id);
175
176
  done({ id, ok: false, text: "the file desk did not answer" });
176
- }, timeoutMs);
177
+ }, timeoutMs) : undefined;
177
178
  pending.set(id, { done, timer });
178
179
  try {
179
180
  connect().write(`${JSON.stringify({ ...request, id, worker })}\n`);
@@ -5,10 +5,8 @@ import type { AgentRun } from "../schemas/findings.ts";
5
5
  import { roleSpec } from "../roles/registry.ts";
6
6
  import { compilePrompt } from "../prompts/compiler.ts";
7
7
  import { EditLog } from "../state/changes.ts";
8
- import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
8
  import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
9
  import { ASK_ENV } from "../ask/relay.ts";
11
- import { STEP_LIMIT_MS } from "../state/step-limits.ts";
12
10
  import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
13
11
  import { ASK_TOOL } from "../ask/types.ts";
14
12
 
@@ -54,7 +52,8 @@ export interface AgentRequest {
54
52
  /** See PiRunOptions.expert. */
55
53
  expert?: { model?: string; thinking: string };
56
54
  thinking?: string;
57
- timeoutMs: number;
55
+ /** Skill files this agent may load; absent keeps pi's own discovery. */
56
+ skills?: string[];
58
57
  cwd: string;
59
58
  signal?: AbortSignal;
60
59
  onUpdate?: (run: AgentRun) => void;
@@ -64,8 +63,6 @@ export interface AgentRequest {
64
63
  stallTimeoutMs?: number;
65
64
  /** Silence allowed while one tool call runs. */
66
65
  toolStallTimeoutMs?: number;
67
- /** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
68
- wrapUpAt?: number;
69
66
  /** Extra tools on top of the role allowlist (the file desk's tools). */
70
67
  extraTools?: readonly string[];
71
68
  env?: Record<string, string>;
@@ -76,76 +73,26 @@ export interface AgentRequest {
76
73
  /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
77
74
  routedFrom?: string;
78
75
  route?: string;
79
- /** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
80
- time?: AgentTime;
81
- /** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
76
+ /** Lets the agent ask the user (ask_user_question, relayed through the master). */
82
77
  onAsk?: RelayAsk;
83
78
  }
84
79
 
85
- /**
86
- * A run under a task time budget. The agent is told its minutes up front,
87
- * warned at 75%, and asked to stop and report when they run out. A worker
88
- * then reports where it left off and how much more it needs; `onTimeUp`
89
- * (the engine asking the user) grants more, and the same agent carries on.
90
- */
91
- export interface AgentTime {
92
- /** Active time shared across attempts and model fallbacks. */
93
- usedMs?: number;
94
- /** Include reporting in the hard allowance. */
95
- strict?: boolean;
96
- /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
97
- endsAt: number;
98
- /** The allotment, as the agent and the lobby see it. */
99
- allotMs: number;
100
- /** What the agent is told about its time, in its context. */
101
- note?: string;
102
- /** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
103
- onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
104
- }
105
-
106
- /** The warning at 75% of an allotment. */
107
- export function headsUpMessage(leftMs: number, allotMs: number): string {
108
- return `bot-lobby: about ${formatMinutes(leftMs)} of your ${formatMinutes(allotMs)} for this step are left. Finish what you have started and make sure it works; leave nice-to-haves.`;
109
- }
110
-
111
- /** The steer when an allotment is spent: a worker says where it left off and what more it needs; any other role reports what it has. */
112
- export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
113
- return canAskForMore
114
- ? `bot-lobby: your ${formatMinutes(allotMs)} for this step are up. Stop here: finish or revert the edit in progress so every file is consistent, start nothing new, and write your report now in the required format, adding \`## Left Off\` (what you were doing and what is still to do) and \`## More Time\` (how many more minutes you need, and why). The user decides whether you get more time to finish.`
115
- : `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
116
- }
117
-
118
- /** The follow-up that sends an agent on once the user gave it more time. */
119
- export function carryOnMessage(extraMs: number): string {
120
- return `bot-lobby: the user gave you ${formatMinutes(extraMs)} more. Carry on from where you left off and finish the step, then write your final report in the required format (leave out Left Off and More Time if you finish).`;
121
- }
122
-
123
- /** Least time an attempt starts with; a retry with less than this left is not started. */
124
- const MIN_ATTEMPT_MS = 60_000;
125
-
126
- export function boundedAgentTime(timeoutMs: number): AgentTime {
127
- const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
128
- return { endsAt: Date.now() + allotMs, allotMs, strict: true };
129
- }
130
-
131
80
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
132
81
  export function watchdogOptions(workflow: {
133
82
  maxAgentRetries: number;
134
83
  stallTimeoutMs: number;
135
84
  toolStallTimeoutMs: number;
136
- wrapUpAt: number;
137
- }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
85
+ }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs"> {
138
86
  return {
139
87
  retries: workflow.maxAgentRetries,
140
88
  stallTimeoutMs: workflow.stallTimeoutMs,
141
89
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
142
- wrapUpAt: workflow.wrapUpAt,
143
90
  };
144
91
  }
145
92
 
146
93
  const activeControllers = new Set<AbortController>();
147
94
 
148
- /** A stall or crash is worth one more try; a spent deadline or a cancel is not. */
95
+ /** A stall or crash is worth one more try; a cancel is not. */
149
96
  function retryable(run: AgentRun): boolean {
150
97
  if (run.status === "failed") return true;
151
98
  return run.status === "timeout" && run.stalled === true;
@@ -153,23 +100,11 @@ function retryable(run: AgentRun): boolean {
153
100
 
154
101
  /**
155
102
  * Run one domain/role agent, retrying transient failures a bounded number of
156
- * times. Cancellation and a spent deadline never retry, so Esc stays responsive
157
- * and a slow agent cannot double its time budget.
103
+ * times. Cancellation never retries, so Esc stays responsive. There is no
104
+ * time limit: an agent works until it reports, is cancelled, or stalls.
158
105
  */
159
106
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
160
- request.time ??= boundedAgentTime(request.timeoutMs);
161
- if (!request.time.strict) {
162
- request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
163
- request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
164
- request.time.strict = true;
165
- }
166
- request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
167
107
  const startedAt = new Date().toISOString();
168
- if (request.time.endsAt <= Date.now()) {
169
- 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 };
170
- request.onUpdate?.(stopped);
171
- return stopped;
172
- }
173
108
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
174
109
  // A failed attempt may have edited files before the retry: the run owns every edit.
175
110
  const edits = new EditLog(request.cwd);
@@ -179,8 +114,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
179
114
  last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
180
115
  request.onAttemptEnd?.(last);
181
116
  if (!retryable(last)) break;
182
- // Under a budget a retry only uses what is left of the allotment.
183
- if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
184
117
  }
185
118
  const edited = edits.list();
186
119
  return edited.length > 0 ? { ...last!, edited } : last!;
@@ -206,7 +139,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
206
139
  ...(request.thinking ? { thinking: request.thinking } : {}),
207
140
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
208
141
  ...(request.route ? { route: request.route } : {}),
209
- ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
210
142
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
211
143
  };
212
144
  }
@@ -225,30 +157,18 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
225
157
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
226
158
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
227
159
  request.onUpdate?.(base);
228
- const time = request.time!;
229
160
  const attemptStart = Date.now();
230
161
  let pausedMs = 0;
231
162
  let asking = 0;
232
163
  let askStarted = 0;
233
- let expired = false;
234
164
  const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
235
- const live = createLiveRun(base, { ...request, onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: (time.usedMs ?? 0) + elapsed(), endsAt: time.endsAt }) }, edits);
165
+ const live = createLiveRun(base, { onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: elapsed() }) }, edits);
236
166
 
237
167
  const controller = new AbortController();
238
168
  activeControllers.add(controller);
239
169
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
240
- let deadline: ReturnType<typeof setTimeout>;
241
- const arm = () => {
242
- clearTimeout(deadline);
243
- deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
244
- };
245
- arm();
246
170
  try {
247
171
  const systemPrompt = request.systemPrompt ?? compilePrompt({ domain: request.domain, role: request.role, ...request.context });
248
- const left = Math.max(1, time.endsAt - Date.now());
249
- const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
250
- const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
251
- const onTimeUp = time?.onTimeUp;
252
172
  const result = await runPiAgent({
253
173
  cwd: request.cwd,
254
174
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
@@ -261,88 +181,55 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
261
181
  request.onUpdate?.(child);
262
182
  },
263
183
  mcpTools: request.mcpTools,
184
+ ...(request.skills ? { skills: request.skills } : {}),
264
185
  ...(request.autoCompact === false ? { autoCompact: false } : {}),
265
186
  task: request.instruction,
266
187
  systemPrompt,
267
188
  tools: toolsFor(request),
268
189
  model: request.model,
269
190
  thinking: request.thinking,
270
- timeoutMs: left,
271
- ...(time ? {
272
- time: {
273
- hardLimit: true,
274
- upAtMs: Math.max(1, left - reportMs),
275
- headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
276
- headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
277
- 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." : ""}`,
278
- graceMs: reportMs,
279
- ...(onTimeUp ? {
280
- onTimeUp: async (report: string) => {
281
- clearTimeout(deadline);
282
- const waiting = Date.now();
283
- let extraMs: number;
284
- try { extraMs = await onTimeUp(live.current(), report); }
285
- finally { pausedMs += Date.now() - waiting; }
286
- time.endsAt += Date.now() - waiting;
287
- if (extraMs > 0) {
288
- time.endsAt = Date.now() + extraMs;
289
- live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
290
- }
291
- arm();
292
- return { extraMs, message: carryOnMessage(extraMs) };
293
- },
294
- } : {}),
295
- },
296
- } : {}),
297
191
  signal,
298
192
  ...(request.onAsk ? { onAsk: request.onAsk } : {}),
299
193
  env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
300
194
  stallTimeoutMs: request.stallTimeoutMs,
301
195
  toolStallTimeoutMs: request.toolStallTimeoutMs,
302
- wrapUpAtMs: wrapUp,
303
196
  onEvent: (event) => {
304
- if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; clearTimeout(deadline); }
197
+ if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; }
305
198
  if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
306
199
  live.onEvent(event);
307
- if (event.type === "answered" && asking === 0) arm();
308
200
  },
309
201
  onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
310
202
  }, run);
311
203
  const current = live.current();
312
204
  const final: AgentRun = {
313
205
  ...base,
314
- status: expired ? "timeout" : result.status,
206
+ status: result.status,
315
207
  output: result.output,
316
- error: expired ? describeError("hit the shared assignment time limit", current, false) : result.error ? describeError(result.error, current, result.stalled === true) : undefined,
208
+ error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
317
209
  usage: result.usage,
318
210
  finishedAt: new Date().toISOString(),
319
- activeMs: (time.usedMs ?? 0) + elapsed(),
211
+ activeMs: elapsed(),
320
212
  turns: current.turns ?? (result.usage.turns || undefined),
321
213
  tools: current.tools,
322
214
  model: result.model ?? current.model,
323
215
  ...(result.stalled ? { stalled: true } : {}),
324
- ...(result.wrappedUp ? { wrappedUp: true } : {}),
325
- ...(result.timeUp || expired ? { timeUp: true } : {}),
326
- ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
327
216
  note: undefined,
328
217
  noteKind: undefined,
329
218
  };
330
219
  request.onUpdate?.(final);
331
220
  return final;
332
221
  } catch (error) {
333
- 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 } : {}) };
222
+ const final: AgentRun = { ...live.current(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, activeMs: elapsed(), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
334
223
  request.onUpdate?.(final);
335
224
  return final;
336
225
  } finally {
337
- clearTimeout(deadline!);
338
- time.usedMs = (time.usedMs ?? 0) + elapsed();
339
226
  activeControllers.delete(controller);
340
227
  }
341
228
  }
342
229
 
343
- /** Name what the agent was doing when a stall or deadline hit. */
230
+ /** Name what the agent was doing when a stall hit. */
344
231
  function describeError(error: string, current: AgentRun, stalled: boolean): string {
345
- if (!stalled && !/time limit/.test(error)) return error;
232
+ if (!stalled) return error;
346
233
  const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
347
234
  return `${error}${doing}`;
348
235
  }
@@ -23,7 +23,6 @@ export const EXPERT_TOOL = "ask_expert";
23
23
  /** Consults one step may make: advice on the hard part, not a second worker. */
24
24
  export const MAX_CONSULTS = 2;
25
25
  /** Longest an expert may take to answer. */
26
- export const EXPERT_TIMEOUT_MS = 5 * 60 * 1000;
27
26
  /** Marks an expert's run, so its host gives it an adviser's note rather than a child's. */
28
27
  export const EXPERT_ENV = "BOT_LOBBY_EXPERT";
29
28
 
@@ -85,7 +84,7 @@ export interface AgentPolicy {
85
84
  depth: number;
86
85
  }
87
86
 
88
- /** The host owns quotas, deadlines, file identities and child lifetimes, outside model context. */
87
+ /** The host owns quotas, file identities and child lifetimes, outside model context. */
89
88
  export async function delegationHost(options: PiRunOptions, run: ProcessRunner) {
90
89
  const config = loadConfig();
91
90
  const id = options.agent?.runId ?? randomUUID();
@@ -112,14 +111,11 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
112
111
  desk ??= ownedDesk;
113
112
  const controller = new AbortController();
114
113
  const lifetime = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
115
- let timeController = new AbortController();
116
- const childSignal = () => AbortSignal.any([lifetime, timeController.signal]);
114
+ const childSignal = () => lifetime;
117
115
  const key = `${options.cwd}:${agent.taskId}`;
118
116
  const pool = pools.get(key) ?? { active: 0, users: 0, waiters: [] };
119
117
  pool.users += 1;
120
118
  pools.set(key, pool);
121
- let deadlineAt = Date.now() + (options.time?.upAtMs ?? options.timeoutMs);
122
- let deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
123
119
  const children = new Map<string, AgentRun>();
124
120
  const pending = new Set<Promise<AgentRun>>();
125
121
  const parentDeskId = options.env?.[DESK_ENV.worker] ?? id;
@@ -137,18 +133,16 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
137
133
 
138
134
  const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
139
135
  const childId = randomUUID();
140
- 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 } : {}) };
136
+ 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 } : {}) };
141
137
  const edits = new EditLog(options.cwd);
142
- const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
138
+ const live = createLiveRun(base, { onUpdate: update }, edits);
143
139
  update(base);
144
140
  try {
145
141
  const configured = options.effortProfile ?? { model: options.model, thinking: options.thinking ?? "medium" };
146
142
  const effort = options.effort ?? effortFor();
147
143
  const route = await effort.childRoute?.(brief, configured, { context: options.task, signal }).catch(() => undefined);
148
144
  const attempt = async (model: string | undefined, thinking: string) => {
149
- const left = deadlineAt - Date.now();
150
145
  signal.throwIfAborted();
151
- if (left <= 0) throw new Error("The parent's time is up.");
152
146
  const routed = route && model === route.model && thinking === route.thinking;
153
147
  live.annotate({ model, thinking, route: routed ? routeLabel(route) : undefined, routedFrom: routed ? profileLabel(route.from) : undefined, note: routed ? routeLabel(route) : undefined, noteKind: "info" });
154
148
  return runPiAgent({
@@ -159,10 +153,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
159
153
  thinking,
160
154
  tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL && tool !== EXPERT_TOOL), ...(childDesk ? childDesk.tools().filter((tool) => !PEER_TOOLS.includes(tool)) : [])])],
161
155
  mcpTools,
162
- timeoutMs: left,
163
156
  signal,
164
- time: undefined,
165
- wrapUpAtMs: Math.floor(left * 0.85),
166
157
  delegationQuota: undefined,
167
158
  onChildRun: undefined,
168
159
  onEvent: live.onEvent,
@@ -172,7 +163,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
172
163
  };
173
164
  let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
174
165
  let retried = false;
175
- if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted && deadlineAt - Date.now() >= 60_000) {
166
+ if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted) {
176
167
  retried = true;
177
168
  result = await attempt(configured.model, configured.thinking);
178
169
  }
@@ -180,7 +171,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
180
171
  update(final);
181
172
  return final;
182
173
  } catch (error) {
183
- 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 };
174
+ const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
184
175
  update(final);
185
176
  return final;
186
177
  } finally {
@@ -192,14 +183,12 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
192
183
  quota.consults ??= 0;
193
184
  const consultations = new Map<string, AbortController>();
194
185
  const consult = async (question: string, signal: AbortSignal): Promise<{ ok: boolean; text: string }> => {
195
- const left = Math.min(EXPERT_TIMEOUT_MS, deadlineAt - Date.now());
196
- if (left < 30_000) return { ok: false, text: "Too little of your time is left to consult; carry on with your best reading." };
197
186
  // The oracle resolves the expert (its session model when settings name none); settings alone, then the worker's own, without it.
198
187
  const settled = options.expert ?? expertProfile(config);
199
188
  const expert = { model: settled.model ?? options.model, thinking: settled.thinking };
200
189
  const expertId = randomUUID();
201
- const base: AgentRun = { runId: expertId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: "reviewer", instruction: question, stepInstruction: options.task, status: "running", endsAt: Date.now() + left, output: "", attempts: 1, startedAt: new Date().toISOString(), thinking: expert.thinking, ...(expert.model ? { model: expert.model } : {}), note: "expert consult", noteKind: "info" };
202
- const live = createLiveRun(base, { onUpdate: update, timeoutMs: left }, new EditLog(options.cwd));
190
+ const base: AgentRun = { runId: expertId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: "reviewer", instruction: question, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), thinking: expert.thinking, ...(expert.model ? { model: expert.model } : {}), note: "expert consult", noteKind: "info" };
191
+ const live = createLiveRun(base, { onUpdate: update }, new EditLog(options.cwd));
203
192
  update(base);
204
193
  let final: AgentRun;
205
194
  try {
@@ -212,8 +201,6 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
212
201
  mcpTools: [],
213
202
  ...(expert.model ? { model: expert.model } : {}),
214
203
  thinking: expert.thinking,
215
- timeoutMs: left,
216
- wrapUpAtMs: Math.floor(left * 0.8),
217
204
  ...(options.stallTimeoutMs !== undefined ? { stallTimeoutMs: options.stallTimeoutMs } : {}),
218
205
  ...(options.toolStallTimeoutMs !== undefined ? { toolStallTimeoutMs: options.toolStallTimeoutMs } : {}),
219
206
  signal,
@@ -275,7 +262,6 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
275
262
  const results = await Promise.all(runs);
276
263
  return { ok: true, text: JSON.stringify(results.map((child) => ({ runId: child.runId, status: child.status, report: child.output.slice(0, 12_000), reportPath: join(dataRoot(detectProjectRoot(options.cwd, CONFIG_DIR_NAME), CONFIG_DIR_NAME), "delegations", `${child.runId}.json`), error: child.error, usage: child.usage }))) };
277
264
  }, () => controller.abort()).catch(async (error) => {
278
- clearTimeout(deadline);
279
265
  if (ownedDesk) await ownedDesk.close();
280
266
  pool.users -= 1;
281
267
  if (pool.users === 0) pools.delete(key);
@@ -286,25 +272,17 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
286
272
  return {
287
273
  tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools,
288
274
  mcpTools,
289
- 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 }) },
290
- note: `${consults ? `When you are stuck on a hard step after a real attempt, or about to make a choice you are unsure of (a tricky algorithm, a subtle bug, an unfamiliar API), ${EXPERT_TOOL} asks a stronger model for advice, at most ${MAX_CONSULTS} times; it reads the repository and answers, you still do the work. ` : ""}${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?.usesClaims ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
275
+ 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 }) },
276
+ note: `${consults ? `When you are stuck on a hard step after a real attempt, or about to make a choice you are unsure of (a tricky algorithm, a subtle bug, an unfamiliar API), ${EXPERT_TOOL} asks a stronger model for advice, at most ${MAX_CONSULTS} times; it reads the repository and answers, you still do the work. ` : ""}${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 cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk?.usesClaims ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
291
277
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
292
278
  desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
293
279
  options.onStart?.(handle);
294
280
  },
295
281
  get children() { return [...children.values()]; },
296
- pause() { clearTimeout(deadline); },
297
- extend(ms: number, reset = false) {
298
- deadlineAt = reset ? Date.now() + ms : deadlineAt + ms;
299
- clearTimeout(deadline);
300
- if (timeController.signal.aborted) timeController = new AbortController();
301
- deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
302
- },
303
282
  async close() {
304
283
  if (closed) return;
305
284
  closed = true;
306
285
  controller.abort();
307
- clearTimeout(deadline);
308
286
  await Promise.allSettled([...pending]);
309
287
  await server.close();
310
288
  if (ownedDesk) { ownedDesk.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
@@ -3,7 +3,7 @@ import type { AgentRun } from "../schemas/findings.ts";
3
3
  import type { PiStreamEvent } from "./pi-runner.ts";
4
4
  import { EditLog } from "../state/changes.ts";
5
5
  import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
6
- import { shortDuration, truncate } from "../text.ts";
6
+ import { truncate } from "../text.ts";
7
7
 
8
8
  const THROTTLE_MS = 2000;
9
9
 
@@ -12,7 +12,7 @@ const THROTTLE_MS = 2000;
12
12
  * `onUpdate`: activity changes, retries and notes immediately, counters and
13
13
  * heartbeats at most every couple of seconds.
14
14
  */
15
- export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate" | "timeoutMs" | "time">, edits: EditLog) {
15
+ export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate">, edits: EditLog) {
16
16
  let state: AgentRun = base;
17
17
  let lastEmit = 0;
18
18
  const emit = (patch: Partial<AgentRun>, force: boolean) => {
@@ -55,22 +55,11 @@ export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpd
55
55
  case "compaction_end":
56
56
  emit({ note: event.error, noteKind: event.error ? "warning" : undefined }, true);
57
57
  return;
58
- case "wrap_up":
59
- emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
60
- return;
61
- case "time_up":
62
- emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
63
- return;
64
- case "extended":
65
- emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
66
- return;
67
58
  case "asking":
68
59
  emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
69
60
  return;
70
61
  case "answered":
71
- // Its time waited with it.
72
- if (request.time) request.time.endsAt += event.waitedMs;
73
- emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
62
+ emit({ note: "you answered; carrying on", noteKind: "info" }, true);
74
63
  return;
75
64
  case "usage": {
76
65
  const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };