@a-t-h-i/bot-lobby 0.6.53 → 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 (60) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/prompts/expert.md +17 -0
  4. package/prompts/goal-check.md +44 -0
  5. package/prompts/master.md +70 -32
  6. package/prompts/worker.md +25 -11
  7. package/src/desk/board-client.ts +65 -0
  8. package/src/desk/board.ts +200 -0
  9. package/src/desk/client-extension.ts +31 -1
  10. package/src/desk/ipc.ts +16 -4
  11. package/src/desk/session.ts +129 -1
  12. package/src/execution/agent-runner.ts +27 -132
  13. package/src/execution/delegation.ts +112 -38
  14. package/src/execution/live-run.ts +3 -14
  15. package/src/execution/pi-runner.ts +30 -163
  16. package/src/execution/worker-trees.ts +396 -0
  17. package/src/index.ts +6 -0
  18. package/src/lobby/feed.ts +1 -1
  19. package/src/lobby/host.ts +2 -0
  20. package/src/lobby/planner.ts +2 -2
  21. package/src/lobby/pr-review.ts +3 -3
  22. package/src/lobby/quickfix.ts +4 -3
  23. package/src/lobby/recovery.ts +2 -10
  24. package/src/lobby/runtime.ts +1 -1
  25. package/src/lobby/service.ts +3 -1
  26. package/src/master/goal-check.ts +114 -0
  27. package/src/master/master.ts +60 -24
  28. package/src/master/research.ts +3 -6
  29. package/src/pi/commands.ts +5 -45
  30. package/src/pi/delegation.ts +23 -4
  31. package/src/pi/events.ts +6 -19
  32. package/src/pi/grants.ts +89 -0
  33. package/src/pi/model-settings.ts +2 -4
  34. package/src/pi/model-support.ts +12 -5
  35. package/src/pi/route.ts +1 -1
  36. package/src/pi/run-summary.ts +3 -7
  37. package/src/pi/start-flags.ts +3 -3
  38. package/src/pi/start-task.ts +2 -8
  39. package/src/pi/tools.ts +10 -12
  40. package/src/roles/worker.ts +0 -18
  41. package/src/schemas/configuration.ts +81 -39
  42. package/src/schemas/findings.ts +0 -12
  43. package/src/schemas/task.ts +12 -1
  44. package/src/state/metrics.ts +1 -1
  45. package/src/state/work-clock.ts +140 -0
  46. package/src/state/work-time.ts +4 -7
  47. package/src/webui/api/index.ts +2 -1
  48. package/src/webui/api/settings.ts +6 -18
  49. package/src/webui/dev/fake-service.ts +14 -0
  50. package/src/webui/protocol.ts +23 -0
  51. package/src/workflow/workflow.ts +204 -344
  52. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
  53. package/webui/dist/assets/index-BkXhtB6A.css +2 -0
  54. package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
  55. package/webui/dist/build.json +10 -6
  56. package/webui/dist/index.html +2 -2
  57. package/src/state/budget.ts +0 -301
  58. package/src/state/step-limits.ts +0 -41
  59. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  60. package/webui/dist/assets/index-DsVdXmh5.css +0 -2
@@ -8,10 +8,20 @@ import { basename } from "node:path";
8
8
  import type { AgentHandle } from "../execution/agent-runner.ts";
9
9
  import { FileDesk, type Claim, type DeskEvent, type FileView, type Handover, type WorkerId } from "./desk.ts";
10
10
  import { startDeskServer, type DeskRequest, type DeskResponse, type DeskServer } from "./ipc.ts";
11
+ import { appendPost, formatPost, isPostKind, PUSHED_KINDS, SUBSCRIPTIONS, subscriptionFor, type BoardPost } from "./board.ts";
11
12
 
12
13
  /** Tools the desk adds to every parallel worker's allowlist. */
13
14
  export const DESK_TOOLS: readonly string[] = ["claim_file", "handover_file", "my_files", "wait_for_files"];
14
15
 
16
+ /** The desk tools of a worker with a checkout of its own: questions only, no claims. */
17
+ export const PEER_TOOLS: readonly string[] = ["ask_peer", "answer_peer"];
18
+
19
+ /** How long `ask_peer` waits for an answer by default, and at most. */
20
+ export const ASK_WAIT_MS = 90_000;
21
+ export const MAX_ASK_WAIT_MS = 180_000;
22
+ const MAX_QUESTION = 800;
23
+ const MAX_ANSWER = 1500;
24
+
15
25
  /** Env names the desk client extension reads inside a worker process. */
16
26
  export const DESK_ENV = { address: "BOT_LOBBY_DESK", worker: "BOT_LOBBY_DESK_ID" } as const;
17
27
 
@@ -29,6 +39,27 @@ function label(worker: WorkerId): string {
29
39
  return names[worker] ?? worker.toUpperCase();
30
40
  }
31
41
 
42
+ /** A peer named the way workers know each other (DEV, DESIGN, QA) or by domain. */
43
+ export function peerId(name: string): WorkerId {
44
+ const key = name.trim().toLowerCase();
45
+ const ids: Record<string, string> = { dev: "backend", backend: "backend", design: "designer", designer: "designer", qa: "qa" };
46
+ return ids[key] ?? name.trim();
47
+ }
48
+
49
+ function flat(text: string, max: number): string {
50
+ const value = text.replace(/\s+/g, " ").trim();
51
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
52
+ }
53
+
54
+ interface PeerQuestion {
55
+ id: number;
56
+ from: WorkerId;
57
+ to: WorkerId;
58
+ question: string;
59
+ resolve: (answer: string | undefined) => void;
60
+ timer: ReturnType<typeof setTimeout>;
61
+ }
62
+
32
63
  function queueLines(queue: readonly Claim[]): string {
33
64
  return queue.map((claim, index) => `${index + 1}) ${label(claim.worker)} — ${claim.intent}`).join("; ");
34
65
  }
@@ -58,6 +89,12 @@ function describeHeld(files: readonly FileView[]): string {
58
89
 
59
90
  export interface DeskSessionOptions {
60
91
  cwd: string;
92
+ /** The task board's file: workers post to it through the desk, and contracts and blockers reach the other workers at once. */
93
+ boardPath?: string;
94
+ /** False when every worker has a checkout of its own: edits need no claim, and the desk only carries questions and the board. */
95
+ claims?: boolean;
96
+ /** Workers of one parallel batch may ask each other questions (`ask_peer`). */
97
+ peers?: boolean;
61
98
  /** A worker that finished without handing a file over leaves this note for the next worker. */
62
99
  onHandover?: (handover: Handover) => void;
63
100
  }
@@ -75,6 +112,8 @@ export class DeskSession {
75
112
  private readonly handles = new Map<WorkerId, AgentHandle>();
76
113
  private readonly greeted = new Set<WorkerId>();
77
114
  private readonly options: DeskSessionOptions;
115
+ private readonly questions = new Map<number, PeerQuestion>();
116
+ private nextQuestion = 1;
78
117
 
79
118
  constructor(options: DeskSessionOptions) {
80
119
  this.options = options;
@@ -97,6 +136,16 @@ export class DeskSession {
97
136
  this.handles.set(worker, handle);
98
137
  }
99
138
 
139
+ /** False when every worker has a checkout of its own and edits need no claim. */
140
+ get usesClaims(): boolean {
141
+ return this.options.claims !== false;
142
+ }
143
+
144
+ /** The desk tools its workers get: claims, questions, or both. */
145
+ tools(): readonly string[] {
146
+ return [...(this.usesClaims ? DESK_TOOLS : []), ...(this.options.peers ? PEER_TOOLS : [])];
147
+ }
148
+
100
149
  /** True once the worker's desk extension has checked in. */
101
150
  greetedBy(worker: WorkerId): boolean {
102
151
  return this.greeted.has(worker);
@@ -106,8 +155,13 @@ export class DeskSession {
106
155
  release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
107
156
  this.releaseFiles(worker, note);
108
157
  this.handles.delete(worker);
158
+ // A finished worker can no longer answer, and its own questions need no answer.
159
+ for (const question of [...this.questions.values()]) {
160
+ if (question.to === worker || question.from === worker) this.settle(question, undefined);
161
+ }
109
162
  }
110
163
 
164
+
111
165
  /** A coordinating worker releases claims but still receives steering messages. */
112
166
  releaseFiles(worker: WorkerId, note: (path: string, next: Claim) => string): void {
113
167
  this.handles.get(worker)?.annotate({ waitingFor: undefined });
@@ -119,6 +173,7 @@ export class DeskSession {
119
173
  }
120
174
 
121
175
  async close(): Promise<void> {
176
+ for (const question of [...this.questions.values()]) this.settle(question, undefined);
122
177
  if (this.server) sessions.delete(this.server.address);
123
178
  this.desk.close();
124
179
  await this.server?.close();
@@ -158,13 +213,19 @@ export class DeskSession {
158
213
  return { ok: true, text: this.mine(worker) };
159
214
  case "wait":
160
215
  return this.wait(worker, Math.min(MAX_WAIT_MS, Math.max(1000, request.timeoutMs ?? MAX_WAIT_MS)));
216
+ case "post":
217
+ return this.post(request.from?.trim() || worker, worker, request.kind ?? "", request.text ?? "", request.files ?? []);
218
+ case "ask":
219
+ return this.ask(worker, request.to ?? "", request.path ?? "", request.text ?? "", Math.min(MAX_ASK_WAIT_MS, Math.max(5000, request.timeoutMs ?? ASK_WAIT_MS)));
220
+ case "answer":
221
+ return this.answer(worker, request.questionId ?? 0, request.text ?? "");
161
222
  default:
162
223
  return { ok: false, text: `unknown desk operation ${String(request.op)}` };
163
224
  }
164
225
  }
165
226
 
166
227
  private check(worker: WorkerId, path: string): Omit<DeskResponse, "id"> {
167
- if (this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
228
+ if (this.options.claims === false || this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
168
229
  const key = this.desk.normalize(path);
169
230
  const holder = this.desk.holderOf(path);
170
231
  if (!holder) {
@@ -217,6 +278,73 @@ export class DeskSession {
217
278
  if (queuedFor.length === 0) return { ok: true, text: "You are not waiting for any file." };
218
279
  return { ok: true, text: `Still waiting:\n${this.mine(worker)}\nIf nothing else is left, finish your report and list the dependency under Notes; files are handed over automatically when their holder finishes.` };
219
280
  }
281
+
282
+ private post(from: string, worker: WorkerId, kind: string, text: string, files: readonly string[]): Omit<DeskResponse, "id"> {
283
+ if (!this.options.boardPath) return { ok: false, text: "This task has no board." };
284
+ if (!isPostKind(kind)) return { ok: false, text: `Unknown post kind "${kind}".` };
285
+ let post: BoardPost;
286
+ try {
287
+ post = appendPost(this.options.boardPath, { from, kind, text, files: files.map((file) => this.desk.normalize(file)) });
288
+ } catch (error) {
289
+ return { ok: false, text: (error as Error).message };
290
+ }
291
+ let told: string[] = [];
292
+ if (PUSHED_KINDS.has(post.kind)) {
293
+ // Only the batch's named workers hear posts as they land; children read the board when they need it.
294
+ told = [...this.handles.keys()].filter((peer) => peer !== worker && peer in SUBSCRIPTIONS && subscriptionFor(peer).includes(post.kind));
295
+ for (const peer of told) this.steer(peer, `bot-lobby board: ${formatPost(post)}`);
296
+ }
297
+ return { ok: true, text: `Posted #${post.seq}.${told.length > 0 ? ` ${told.map(label).join(", ")} ${told.length === 1 ? "was" : "were"} told now.` : ""}` };
298
+ }
299
+
300
+ private async ask(worker: WorkerId, to: string, path: string, question: string, timeoutMs: number): Promise<Omit<DeskResponse, "id">> {
301
+ if (!this.options.peers) return { ok: false, text: "No peers work alongside you here." };
302
+ const text = flat(question, MAX_QUESTION);
303
+ if (!text) return { ok: false, text: "ask_peer needs a question." };
304
+ if (path.trim() && !this.usesClaims) return { ok: false, text: "Every worker in this batch has a checkout of its own, so nobody holds files: name the peer (DEV, DESIGN or QA) instead." };
305
+ const target = path.trim() ? this.desk.holderOf(path)?.worker : to.trim() ? peerId(to) : undefined;
306
+ if (!target) return { ok: false, text: path.trim() ? `Nobody holds \`${this.desk.normalize(path)}\`; read it yourself.` : "Name the peer (DEV, DESIGN or QA) or a file its holder should answer about." };
307
+ if (target === worker) return { ok: false, text: "That is you." };
308
+ if (!this.handles.has(target)) return { ok: false, text: `${label(target)} is not working right now. Post a \`question\` on the board instead; the oracle sees it.` };
309
+ // A worker waiting on its own question cannot answer until that returns: asking it would chain the waits (or close a cycle).
310
+ const theirs = [...this.questions.values()].find((entry) => entry.from === target);
311
+ if (theirs?.to === worker) return { ok: false, text: `${label(target)} is waiting on your answer to its question #${theirs.id} ("${theirs.question}"). Answer it with answer_peer first.` };
312
+ if (theirs) return { ok: false, text: `${label(target)} is waiting on an answer from ${label(theirs.to)} and cannot answer yet. Carry on with your other work and ask again shortly, or post a \`question\` on the board.` };
313
+ const id = this.nextQuestion++;
314
+ const answer = await new Promise<string | undefined>((resolve) => {
315
+ const timer = setTimeout(() => this.settle(this.questions.get(id), undefined), timeoutMs);
316
+ this.questions.set(id, { id, from: worker, to: target, question: text, resolve, timer });
317
+ this.steer(target, `bot-lobby desk: question #${id} from ${label(worker)}: ${text} Answer it now with answer_peer (id ${id}) in a line or two, from what you know or have built, then carry on with your own work. It is a question, not a task: you take work only from your brief.`);
318
+ this.handles.get(worker)?.annotate({ note: `asked ${label(target)}`, noteKind: "info" });
319
+ });
320
+ if (answer === undefined) {
321
+ return { ok: true, text: `${label(target)} did not answer within ${Math.round(timeoutMs / 1000)} seconds (it may be in a long command, or it finished). Carry on with your best reading of the brief and the board, and list the open question under Notes.` };
322
+ }
323
+ if (this.options.boardPath) {
324
+ try {
325
+ appendPost(this.options.boardPath, { from: worker, kind: "note", text: `Asked ${label(target)}: ${text} Answer: ${answer}` });
326
+ } catch {
327
+ // The answer still reaches the asker.
328
+ }
329
+ }
330
+ return { ok: true, text: `${label(target)} answers: ${answer}` };
331
+ }
332
+
333
+ private answer(worker: WorkerId, id: number, text: string): Omit<DeskResponse, "id"> {
334
+ const question = this.questions.get(id);
335
+ if (!question || question.to !== worker) return { ok: false, text: `There is no open question #${id} for you (it may have timed out).` };
336
+ const answer = flat(text, MAX_ANSWER);
337
+ if (!answer) return { ok: false, text: "answer_peer needs an answer." };
338
+ this.settle(question, answer);
339
+ return { ok: true, text: `Answer sent to ${label(question.from)}.` };
340
+ }
341
+
342
+ private settle(question: PeerQuestion | undefined, answer: string | undefined): void {
343
+ if (!question || !this.questions.has(question.id)) return;
344
+ clearTimeout(question.timer);
345
+ this.questions.delete(question.id);
346
+ question.resolve(answer);
347
+ }
220
348
  }
221
349
 
222
350
  /** A handover note for `next`, written from a finished worker's report. */
@@ -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
 
@@ -44,13 +42,18 @@ export interface AgentRequest {
44
42
  instruction: string;
45
43
  /** Prompt layers selected for this run. */
46
44
  context: AgentContext;
45
+ /** A whole system prompt of its own instead of the compiled role layers (the goal check). */
46
+ systemPrompt?: string;
47
47
  model?: string;
48
48
  mcpTools?: string[];
49
49
  /** False when the agent's settings turn its auto-compaction off. */
50
50
  autoCompact?: boolean;
51
- delegationQuota?: { used: number };
51
+ delegationQuota?: { used: number; consults?: number };
52
+ /** See PiRunOptions.expert. */
53
+ expert?: { model?: string; thinking: string };
52
54
  thinking?: string;
53
- timeoutMs: number;
55
+ /** Skill files this agent may load; absent keeps pi's own discovery. */
56
+ skills?: string[];
54
57
  cwd: string;
55
58
  signal?: AbortSignal;
56
59
  onUpdate?: (run: AgentRun) => void;
@@ -60,8 +63,6 @@ export interface AgentRequest {
60
63
  stallTimeoutMs?: number;
61
64
  /** Silence allowed while one tool call runs. */
62
65
  toolStallTimeoutMs?: number;
63
- /** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
64
- wrapUpAt?: number;
65
66
  /** Extra tools on top of the role allowlist (the file desk's tools). */
66
67
  extraTools?: readonly string[];
67
68
  env?: Record<string, string>;
@@ -72,76 +73,26 @@ export interface AgentRequest {
72
73
  /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
73
74
  routedFrom?: string;
74
75
  route?: string;
75
- /** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
76
- time?: AgentTime;
77
- /** 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). */
78
77
  onAsk?: RelayAsk;
79
78
  }
80
79
 
81
- /**
82
- * A run under a task time budget. The agent is told its minutes up front,
83
- * warned at 75%, and asked to stop and report when they run out. A worker
84
- * then reports where it left off and how much more it needs; `onTimeUp`
85
- * (the engine asking the user) grants more, and the same agent carries on.
86
- */
87
- export interface AgentTime {
88
- /** Active time shared across attempts and model fallbacks. */
89
- usedMs?: number;
90
- /** Include reporting in the hard allowance. */
91
- strict?: boolean;
92
- /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
93
- endsAt: number;
94
- /** The allotment, as the agent and the lobby see it. */
95
- allotMs: number;
96
- /** What the agent is told about its time, in its context. */
97
- note?: string;
98
- /** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
99
- onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
100
- }
101
-
102
- /** The warning at 75% of an allotment. */
103
- export function headsUpMessage(leftMs: number, allotMs: number): string {
104
- 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.`;
105
- }
106
-
107
- /** 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. */
108
- export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
109
- return canAskForMore
110
- ? `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.`
111
- : `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
112
- }
113
-
114
- /** The follow-up that sends an agent on once the user gave it more time. */
115
- export function carryOnMessage(extraMs: number): string {
116
- 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).`;
117
- }
118
-
119
- /** Least time an attempt starts with; a retry with less than this left is not started. */
120
- const MIN_ATTEMPT_MS = 60_000;
121
-
122
- export function boundedAgentTime(timeoutMs: number): AgentTime {
123
- const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
124
- return { endsAt: Date.now() + allotMs, allotMs, strict: true };
125
- }
126
-
127
80
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
128
81
  export function watchdogOptions(workflow: {
129
82
  maxAgentRetries: number;
130
83
  stallTimeoutMs: number;
131
84
  toolStallTimeoutMs: number;
132
- wrapUpAt: number;
133
- }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
85
+ }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs"> {
134
86
  return {
135
87
  retries: workflow.maxAgentRetries,
136
88
  stallTimeoutMs: workflow.stallTimeoutMs,
137
89
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
138
- wrapUpAt: workflow.wrapUpAt,
139
90
  };
140
91
  }
141
92
 
142
93
  const activeControllers = new Set<AbortController>();
143
94
 
144
- /** 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. */
145
96
  function retryable(run: AgentRun): boolean {
146
97
  if (run.status === "failed") return true;
147
98
  return run.status === "timeout" && run.stalled === true;
@@ -149,23 +100,11 @@ function retryable(run: AgentRun): boolean {
149
100
 
150
101
  /**
151
102
  * Run one domain/role agent, retrying transient failures a bounded number of
152
- * times. Cancellation and a spent deadline never retry, so Esc stays responsive
153
- * 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.
154
105
  */
155
106
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
156
- request.time ??= boundedAgentTime(request.timeoutMs);
157
- if (!request.time.strict) {
158
- request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
159
- request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
160
- request.time.strict = true;
161
- }
162
- request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
163
107
  const startedAt = new Date().toISOString();
164
- if (request.time.endsAt <= Date.now()) {
165
- 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 };
166
- request.onUpdate?.(stopped);
167
- return stopped;
168
- }
169
108
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
170
109
  // A failed attempt may have edited files before the retry: the run owns every edit.
171
110
  const edits = new EditLog(request.cwd);
@@ -175,8 +114,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
175
114
  last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
176
115
  request.onAttemptEnd?.(last);
177
116
  if (!retryable(last)) break;
178
- // Under a budget a retry only uses what is left of the allotment.
179
- if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
180
117
  }
181
118
  const edited = edits.list();
182
119
  return edited.length > 0 ? { ...last!, edited } : last!;
@@ -202,7 +139,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
202
139
  ...(request.thinking ? { thinking: request.thinking } : {}),
203
140
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
204
141
  ...(request.route ? { route: request.route } : {}),
205
- ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
206
142
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
207
143
  };
208
144
  }
@@ -221,34 +157,23 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
221
157
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
222
158
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
223
159
  request.onUpdate?.(base);
224
- const time = request.time!;
225
160
  const attemptStart = Date.now();
226
161
  let pausedMs = 0;
227
162
  let asking = 0;
228
163
  let askStarted = 0;
229
- let expired = false;
230
164
  const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
231
- 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);
232
166
 
233
167
  const controller = new AbortController();
234
168
  activeControllers.add(controller);
235
169
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
236
- let deadline: ReturnType<typeof setTimeout>;
237
- const arm = () => {
238
- clearTimeout(deadline);
239
- deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
240
- };
241
- arm();
242
170
  try {
243
- const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
244
- const left = Math.max(1, time.endsAt - Date.now());
245
- const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
246
- const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
247
- const onTimeUp = time?.onTimeUp;
171
+ const systemPrompt = request.systemPrompt ?? compilePrompt({ domain: request.domain, role: request.role, ...request.context });
248
172
  const result = await runPiAgent({
249
173
  cwd: request.cwd,
250
174
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
251
175
  delegationQuota: quota,
176
+ ...(request.expert ? { expert: request.expert } : {}),
252
177
  effort: request.effort,
253
178
  effortProfile: request.effortProfile,
254
179
  onChildRun: (child) => {
@@ -256,88 +181,55 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
256
181
  request.onUpdate?.(child);
257
182
  },
258
183
  mcpTools: request.mcpTools,
184
+ ...(request.skills ? { skills: request.skills } : {}),
259
185
  ...(request.autoCompact === false ? { autoCompact: false } : {}),
260
186
  task: request.instruction,
261
187
  systemPrompt,
262
188
  tools: toolsFor(request),
263
189
  model: request.model,
264
190
  thinking: request.thinking,
265
- timeoutMs: left,
266
- ...(time ? {
267
- time: {
268
- hardLimit: true,
269
- upAtMs: Math.max(1, left - reportMs),
270
- headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
271
- headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
272
- 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." : ""}`,
273
- graceMs: reportMs,
274
- ...(onTimeUp ? {
275
- onTimeUp: async (report: string) => {
276
- clearTimeout(deadline);
277
- const waiting = Date.now();
278
- let extraMs: number;
279
- try { extraMs = await onTimeUp(live.current(), report); }
280
- finally { pausedMs += Date.now() - waiting; }
281
- time.endsAt += Date.now() - waiting;
282
- if (extraMs > 0) {
283
- time.endsAt = Date.now() + extraMs;
284
- live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
285
- }
286
- arm();
287
- return { extraMs, message: carryOnMessage(extraMs) };
288
- },
289
- } : {}),
290
- },
291
- } : {}),
292
191
  signal,
293
192
  ...(request.onAsk ? { onAsk: request.onAsk } : {}),
294
193
  env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
295
194
  stallTimeoutMs: request.stallTimeoutMs,
296
195
  toolStallTimeoutMs: request.toolStallTimeoutMs,
297
- wrapUpAtMs: wrapUp,
298
196
  onEvent: (event) => {
299
- 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; }
300
198
  if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
301
199
  live.onEvent(event);
302
- if (event.type === "answered" && asking === 0) arm();
303
200
  },
304
201
  onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
305
202
  }, run);
306
203
  const current = live.current();
307
204
  const final: AgentRun = {
308
205
  ...base,
309
- status: expired ? "timeout" : result.status,
206
+ status: result.status,
310
207
  output: result.output,
311
- 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,
312
209
  usage: result.usage,
313
210
  finishedAt: new Date().toISOString(),
314
- activeMs: (time.usedMs ?? 0) + elapsed(),
211
+ activeMs: elapsed(),
315
212
  turns: current.turns ?? (result.usage.turns || undefined),
316
213
  tools: current.tools,
317
214
  model: result.model ?? current.model,
318
215
  ...(result.stalled ? { stalled: true } : {}),
319
- ...(result.wrappedUp ? { wrappedUp: true } : {}),
320
- ...(result.timeUp || expired ? { timeUp: true } : {}),
321
- ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
322
216
  note: undefined,
323
217
  noteKind: undefined,
324
218
  };
325
219
  request.onUpdate?.(final);
326
220
  return final;
327
221
  } catch (error) {
328
- 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 };
329
223
  request.onUpdate?.(final);
330
224
  return final;
331
225
  } finally {
332
- clearTimeout(deadline!);
333
- time.usedMs = (time.usedMs ?? 0) + elapsed();
334
226
  activeControllers.delete(controller);
335
227
  }
336
228
  }
337
229
 
338
- /** Name what the agent was doing when a stall or deadline hit. */
230
+ /** Name what the agent was doing when a stall hit. */
339
231
  function describeError(error: string, current: AgentRun, stalled: boolean): string {
340
- if (!stalled && !/time limit/.test(error)) return error;
232
+ if (!stalled) return error;
341
233
  const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
342
234
  return `${error}${doing}`;
343
235
  }
@@ -353,7 +245,10 @@ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: num
353
245
  results[index] = await fn(items[index]!);
354
246
  }
355
247
  });
356
- await Promise.all(workers);
248
+ // Every worker settles before a failure is raised, so no caller cleans up under a sibling still running.
249
+ const settled = await Promise.allSettled(workers);
250
+ const failed = settled.find((entry): entry is PromiseRejectedResult => entry.status === "rejected");
251
+ if (failed) throw failed.reason;
357
252
  return results;
358
253
  }
359
254