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

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 (66) hide show
  1. package/README.md +1 -1
  2. package/THIRD_PARTY_NOTICES.md +12 -1
  3. package/package.json +4 -1
  4. package/prompts/master.md +10 -30
  5. package/prompts/worker.md +8 -12
  6. package/src/desk/ipc.ts +4 -3
  7. package/src/execution/agent-runner.ts +20 -129
  8. package/src/execution/delegation.ts +10 -32
  9. package/src/execution/live-run.ts +3 -14
  10. package/src/execution/pi-runner.ts +36 -166
  11. package/src/harness/acp.ts +272 -0
  12. package/src/harness/catalog.ts +257 -0
  13. package/src/harness/claude-sdk.ts +169 -0
  14. package/src/harness/claude.ts +188 -0
  15. package/src/harness/cli-args.ts +62 -0
  16. package/src/harness/codex.ts +155 -0
  17. package/src/harness/instructions.ts +45 -0
  18. package/src/harness/model-manifest.json +829 -0
  19. package/src/harness/process.ts +175 -0
  20. package/src/harness/runner.ts +188 -0
  21. package/src/harness/status.ts +115 -0
  22. package/src/index.ts +3 -0
  23. package/src/lobby/feed.ts +1 -1
  24. package/src/lobby/host.ts +4 -0
  25. package/src/lobby/planner.ts +4 -3
  26. package/src/lobby/pr-review.ts +4 -3
  27. package/src/lobby/quickfix.ts +8 -4
  28. package/src/lobby/recovery.ts +2 -10
  29. package/src/lobby/runtime.ts +1 -1
  30. package/src/lobby/service.ts +3 -1
  31. package/src/master/goal-check.ts +1 -6
  32. package/src/master/master.ts +12 -24
  33. package/src/master/research.ts +4 -6
  34. package/src/pi/commands.ts +5 -45
  35. package/src/pi/delegation.ts +4 -4
  36. package/src/pi/events.ts +6 -19
  37. package/src/pi/grants.ts +89 -0
  38. package/src/pi/model-settings.ts +2 -4
  39. package/src/pi/model-support.ts +10 -6
  40. package/src/pi/route.ts +1 -1
  41. package/src/pi/run-summary.ts +3 -7
  42. package/src/pi/start-flags.ts +3 -3
  43. package/src/pi/start-task.ts +2 -8
  44. package/src/pi/tools.ts +8 -11
  45. package/src/roles/worker.ts +0 -18
  46. package/src/schemas/configuration.ts +73 -45
  47. package/src/schemas/findings.ts +0 -12
  48. package/src/schemas/harness.ts +122 -0
  49. package/src/schemas/task.ts +0 -1
  50. package/src/state/metrics.ts +1 -1
  51. package/src/state/work-clock.ts +140 -0
  52. package/src/state/work-time.ts +4 -7
  53. package/src/webui/api/index.ts +3 -1
  54. package/src/webui/api/settings.ts +12 -18
  55. package/src/webui/dev/fake-service.ts +27 -0
  56. package/src/webui/protocol.ts +32 -0
  57. package/src/workflow/workflow.ts +23 -318
  58. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-BVehQ7yB.js} +1 -1
  59. package/webui/dist/assets/index-BKiKxUUG.js +114 -0
  60. package/webui/dist/assets/index-DOLQ4FDd.css +2 -0
  61. package/webui/dist/build.json +20 -12
  62. package/webui/dist/index.html +2 -2
  63. package/src/state/budget.ts +0 -301
  64. package/src/state/step-limits.ts +0 -41
  65. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  66. 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.
@@ -5,7 +5,9 @@
5
5
  The web UI's look (colours, radii, shadows, control and dialog styles, the
6
6
  sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
7
7
  fields and drop-down lists, the loading and status animations and the
8
- built-in colour themes) is adapted from T3 Code,
8
+ built-in colour themes), and the third-party harness support (the model
9
+ manifest, CLI flags, prompt injection and ACP handling behind Settings >
10
+ Harnesses), is adapted from T3 Code,
9
11
  https://github.com/pingdotgg/t3code. No T3 Code package is a
10
12
  dependency; the styles and markup below were ported into this repository and
11
13
  rewired to bot-lobby's own data layer. Each adapted file starts with a comment
@@ -37,6 +39,15 @@ naming the file it came from.
37
39
  | `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
38
40
  | `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
39
41
  | `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
42
+ | `src/harness/model-manifest.json` (copied as is) | `apps/server/src/provider/model-manifest.json` |
43
+ | `src/harness/catalog.ts` | `apps/server/src/provider/ClaudeModelCatalog.ts` (effort descriptors and maps), `CodexProvider.ts` (effort levels), `antigravityAuthSupport.ts` (`buildAntigravityAcpSpawnInput`) |
44
+ | `src/harness/cli-args.ts` | `packages/shared/src/cliArgs.ts` (`tokenizeCliArgs`) |
45
+ | `src/harness/instructions.ts` | `packages/provider-core/src/server/runtimeInstructions.ts`, `orchestrationInstructions.ts` (`t3AcpPromptWithInstructions`) |
46
+ | `src/harness/codex.ts` | `apps/server/src/textGeneration/CodexTextGeneration.ts`, `apps/server/src/provider/codexLaunchArgs.ts` |
47
+ | `src/harness/claude.ts` | `apps/server/src/textGeneration/ClaudeTextGeneration.ts`, `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` (auto-compact window) |
48
+ | `src/harness/claude-sdk.ts` | `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` (Agent SDK query options) |
49
+ | `src/harness/acp.ts` | `apps/server/src/provider/acp/AntigravityProtocol.ts` (permission answers), `packages/provider-acp/src/server/sessionConfig.ts` (session option categories) |
50
+ | `src/schemas/harness.ts`, `webui/src/tabs/settings/HarnessesGroup.tsx` | `packages/contracts/src/settings.ts` (provider settings fields), `apps/web/src/components/settings/ProviderInstanceCard.tsx` |
40
51
 
41
52
  ```
42
53
  MIT License
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.56",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -96,5 +96,8 @@
96
96
  "typebox": "1.3.27",
97
97
  "typescript": "^5.7.0",
98
98
  "vite": "8.3.2"
99
+ },
100
+ "optionalDependencies": {
101
+ "@anthropic-ai/claude-agent-sdk": "^0.3.306"
99
102
  }
100
103
  }
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,12 +5,11 @@ 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";
12
+ import type { HarnessRun } from "../schemas/harness.ts";
14
13
 
15
14
  export interface AgentContext {
16
15
  task: string;
@@ -54,7 +53,8 @@ export interface AgentRequest {
54
53
  /** See PiRunOptions.expert. */
55
54
  expert?: { model?: string; thinking: string };
56
55
  thinking?: string;
57
- timeoutMs: number;
56
+ /** Skill files this agent may load; absent keeps pi's own discovery. */
57
+ skills?: string[];
58
58
  cwd: string;
59
59
  signal?: AbortSignal;
60
60
  onUpdate?: (run: AgentRun) => void;
@@ -64,8 +64,6 @@ export interface AgentRequest {
64
64
  stallTimeoutMs?: number;
65
65
  /** Silence allowed while one tool call runs. */
66
66
  toolStallTimeoutMs?: number;
67
- /** Fraction of `timeoutMs` at which the agent is asked to wrap up; 0 disables. */
68
- wrapUpAt?: number;
69
67
  /** Extra tools on top of the role allowlist (the file desk's tools). */
70
68
  extraTools?: readonly string[];
71
69
  env?: Record<string, string>;
@@ -76,56 +74,10 @@ export interface AgentRequest {
76
74
  /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
77
75
  routedFrom?: string;
78
76
  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. */
77
+ /** Lets the agent ask the user (ask_user_question, relayed through the master). */
82
78
  onAsk?: RelayAsk;
83
- }
84
-
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 };
79
+ /** Run on this third-party harness instead of pi. */
80
+ harness?: HarnessRun;
129
81
  }
130
82
 
131
83
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
@@ -133,19 +85,17 @@ export function watchdogOptions(workflow: {
133
85
  maxAgentRetries: number;
134
86
  stallTimeoutMs: number;
135
87
  toolStallTimeoutMs: number;
136
- wrapUpAt: number;
137
- }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs" | "wrapUpAt"> {
88
+ }): Pick<AgentRequest, "retries" | "stallTimeoutMs" | "toolStallTimeoutMs"> {
138
89
  return {
139
90
  retries: workflow.maxAgentRetries,
140
91
  stallTimeoutMs: workflow.stallTimeoutMs,
141
92
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
142
- wrapUpAt: workflow.wrapUpAt,
143
93
  };
144
94
  }
145
95
 
146
96
  const activeControllers = new Set<AbortController>();
147
97
 
148
- /** A stall or crash is worth one more try; a spent deadline or a cancel is not. */
98
+ /** A stall or crash is worth one more try; a cancel is not. */
149
99
  function retryable(run: AgentRun): boolean {
150
100
  if (run.status === "failed") return true;
151
101
  return run.status === "timeout" && run.stalled === true;
@@ -153,23 +103,11 @@ function retryable(run: AgentRun): boolean {
153
103
 
154
104
  /**
155
105
  * 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.
106
+ * times. Cancellation never retries, so Esc stays responsive. There is no
107
+ * time limit: an agent works until it reports, is cancelled, or stalls.
158
108
  */
159
109
  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
110
  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
111
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
174
112
  // A failed attempt may have edited files before the retry: the run owns every edit.
175
113
  const edits = new EditLog(request.cwd);
@@ -179,8 +117,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
179
117
  last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
180
118
  request.onAttemptEnd?.(last);
181
119
  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
120
  }
185
121
  const edited = edits.list();
186
122
  return edited.length > 0 ? { ...last!, edited } : last!;
@@ -206,7 +142,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
206
142
  ...(request.thinking ? { thinking: request.thinking } : {}),
207
143
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
208
144
  ...(request.route ? { route: request.route } : {}),
209
- ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
210
145
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
211
146
  };
212
147
  }
@@ -225,30 +160,18 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
225
160
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
226
161
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
227
162
  request.onUpdate?.(base);
228
- const time = request.time!;
229
163
  const attemptStart = Date.now();
230
164
  let pausedMs = 0;
231
165
  let asking = 0;
232
166
  let askStarted = 0;
233
- let expired = false;
234
167
  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);
168
+ const live = createLiveRun(base, { onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: elapsed() }) }, edits);
236
169
 
237
170
  const controller = new AbortController();
238
171
  activeControllers.add(controller);
239
172
  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
173
  try {
247
174
  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
175
  const result = await runPiAgent({
253
176
  cwd: request.cwd,
254
177
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
@@ -261,88 +184,56 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
261
184
  request.onUpdate?.(child);
262
185
  },
263
186
  mcpTools: request.mcpTools,
187
+ ...(request.skills ? { skills: request.skills } : {}),
264
188
  ...(request.autoCompact === false ? { autoCompact: false } : {}),
265
189
  task: request.instruction,
266
190
  systemPrompt,
267
191
  tools: toolsFor(request),
268
192
  model: request.model,
269
193
  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
194
  signal,
298
195
  ...(request.onAsk ? { onAsk: request.onAsk } : {}),
196
+ ...(request.harness ? { harness: request.harness } : {}),
299
197
  env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
300
198
  stallTimeoutMs: request.stallTimeoutMs,
301
199
  toolStallTimeoutMs: request.toolStallTimeoutMs,
302
- wrapUpAtMs: wrapUp,
303
200
  onEvent: (event) => {
304
- if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; clearTimeout(deadline); }
201
+ if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; }
305
202
  if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
306
203
  live.onEvent(event);
307
- if (event.type === "answered" && asking === 0) arm();
308
204
  },
309
205
  onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
310
206
  }, run);
311
207
  const current = live.current();
312
208
  const final: AgentRun = {
313
209
  ...base,
314
- status: expired ? "timeout" : result.status,
210
+ status: result.status,
315
211
  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,
212
+ error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
317
213
  usage: result.usage,
318
214
  finishedAt: new Date().toISOString(),
319
- activeMs: (time.usedMs ?? 0) + elapsed(),
215
+ activeMs: elapsed(),
320
216
  turns: current.turns ?? (result.usage.turns || undefined),
321
217
  tools: current.tools,
322
218
  model: result.model ?? current.model,
323
219
  ...(result.stalled ? { stalled: true } : {}),
324
- ...(result.wrappedUp ? { wrappedUp: true } : {}),
325
- ...(result.timeUp || expired ? { timeUp: true } : {}),
326
- ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
327
220
  note: undefined,
328
221
  noteKind: undefined,
329
222
  };
330
223
  request.onUpdate?.(final);
331
224
  return final;
332
225
  } 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 } : {}) };
226
+ 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
227
  request.onUpdate?.(final);
335
228
  return final;
336
229
  } finally {
337
- clearTimeout(deadline!);
338
- time.usedMs = (time.usedMs ?? 0) + elapsed();
339
230
  activeControllers.delete(controller);
340
231
  }
341
232
  }
342
233
 
343
- /** Name what the agent was doing when a stall or deadline hit. */
234
+ /** Name what the agent was doing when a stall hit. */
344
235
  function describeError(error: string, current: AgentRun, stalled: boolean): string {
345
- if (!stalled && !/time limit/.test(error)) return error;
236
+ if (!stalled) return error;
346
237
  const doing = current.activity ? ` while ${current.activity}${current.detail ? ` ${current.detail}` : ""}` : "";
347
238
  return `${error}${doing}`;
348
239
  }
@@ -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 };