@a-t-h-i/bot-lobby 0.6.36 → 0.6.38

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 (42) hide show
  1. package/README.md +32 -7
  2. package/package.json +1 -1
  3. package/prompts/master.md +32 -0
  4. package/prompts/worker.md +11 -0
  5. package/src/classifier/effort.ts +14 -2
  6. package/src/classifier/instance.ts +10 -2
  7. package/src/classifier/knowledge.ts +5 -2
  8. package/src/classifier/scripts.ts +76 -0
  9. package/src/execution/agent-runner.ts +75 -14
  10. package/src/execution/delegation.ts +25 -7
  11. package/src/execution/pi-runner.ts +9 -3
  12. package/src/knowledge/selector.ts +2 -0
  13. package/src/lobby/quickfix.ts +10 -2
  14. package/src/lobby/runtime.ts +2 -1
  15. package/src/lobby/service.ts +8 -1
  16. package/src/lobby/sessions.ts +1 -1
  17. package/src/master/master.ts +19 -6
  18. package/src/pi/activity.ts +2 -0
  19. package/src/pi/auto-compaction.ts +4 -4
  20. package/src/pi/events.ts +4 -1
  21. package/src/pi/plan-checklist.ts +4 -2
  22. package/src/pi/start-task.ts +15 -9
  23. package/src/pi/task-title.ts +46 -0
  24. package/src/pi/tools.ts +6 -4
  25. package/src/prompts/compiler.ts +4 -1
  26. package/src/roles/worker.ts +15 -1
  27. package/src/schemas/configuration.ts +5 -2
  28. package/src/schemas/findings.ts +14 -0
  29. package/src/scripts/library.ts +168 -0
  30. package/src/scripts/pick.ts +79 -0
  31. package/src/state/step-limits.ts +41 -0
  32. package/src/state/work-time.ts +16 -11
  33. package/src/webui/api/lobby.ts +1 -1
  34. package/src/webui/api/plan-facts.ts +3 -2
  35. package/src/webui/protocol.ts +1 -1
  36. package/src/workflow/workflow.ts +122 -40
  37. package/webui/dist/assets/index-BFsMFIr5.js +110 -0
  38. package/webui/dist/assets/index-Ccc6rsPD.css +2 -0
  39. package/webui/dist/build.json +99 -79
  40. package/webui/dist/index.html +2 -2
  41. package/webui/dist/assets/index-4Zm1DbOM.css +0 -2
  42. package/webui/dist/assets/index-CjhlNym4.js +0 -109
package/README.md CHANGED
@@ -36,6 +36,11 @@ you to confirm merging and pushing main, then removes its worktree after verifyi
36
36
  Declining or failed verification keeps the worktree. Delivery can also be reviewed in **Tasks**,
37
37
  where you can create a PR instead. The task branch is kept.
38
38
 
39
+ New tasks get a short title summarizing the full request, which also names their branch and
40
+ session. Naming uses low reasoning on `classifier.effort.cheapModel` when configured, otherwise
41
+ the session model. If naming fails or takes more than eight seconds, the request's first content
42
+ words are used. Tasks started from an agreed plan keep that plan's title.
43
+
39
44
  After every worker step, before QA and at completion, the engine runs the project's own linter
40
45
  (ESLint, Biome, Oxlint, Ruff, or a command you set) on the files the task touched. Only problems on
41
46
  changed lines count, and QA judges any lint suppression a worker adds. **Settings → Linting** sets it
@@ -101,6 +106,18 @@ first, searches the codebase for missing information, and records verified findi
101
106
  agent's knowledge. Answers appear in the side pane; the **Chat / Docs** toggle inside the composer
102
107
  switches back to the selected document. Questions do not create tasks.
103
108
 
109
+ ## Scripts
110
+
111
+ Jobs that come back (seeding data, regenerating fixtures, a set of checks before review) are kept
112
+ as scripts in `.pi/bot-lobby/scripts/`, shared by every agent, so a step done once is run the next
113
+ time instead of being worked out again, which saves tokens and time. Each is named for what it
114
+ does, in snake_case with its language's extension (`seed_public_holidays.py`,
115
+ `run_changed_tests.sh`), and its first lines say what it is for and how to run it. The oracle
116
+ decides when a step is worth a script and saves it (`action=script`); workers only propose one, and
117
+ a replaced script is kept under `archive/`. For each step, Jev picks the scripts that do part of it
118
+ and the agent is told to run them (**Settings → Classifier → Shared scripts**; with Jev off, scripts
119
+ whose names share words with the step are suggested).
120
+
104
121
  ## Keys
105
122
 
106
123
  The lobby is keyboard-first, and every action button shows its key (`Archive E`, `Delete Del`).
@@ -126,25 +143,33 @@ are kept in `themes.json` beside it, so every Pi session and project shows them.
126
143
  project knowledge stay in `.pi/bot-lobby/` inside your project.
127
144
 
128
145
 
129
- Requires **Pi 1.0.x** (1.0.1 or later). Every bot-lobby agent can use
130
- `delegate_subtasks` for substantial, separable work. Each parent gets five child
131
- slots total, shared across retries; children inherit its model, thinking, role,
146
+ Requires **Pi 1.0.x** (1.0.1 or later). DEV and DESIGN workers and QA reviewers can use
147
+ `delegate_subtasks` for useful, separable work. Delegation is optional. Each parent gets five child
148
+ slots total, shared across retries; Jev selects each child’s model and reasoning effort from its brief, lowering
149
+ inference for simple work and raising reasoning for complex work on the configured
150
+ model. Children inherit the parent’s role,
132
151
  permissions, worktree and remaining time. Children report to their parent, which
133
152
  integrates and verifies the result. Children cannot delegate further. Active
134
153
  children share `workflow.maxParallelWorkers`; waiting parents do not use those
135
154
  child slots. Cancelling or finishing a parent stops its children and releases
136
155
  their file claims. Full reports are saved under `.pi/bot-lobby/delegations/`.
137
156
 
138
- Active task steps show the parent and active worker children, for example
157
+ Every assignment has a 15-minute total allowance (or its shorter configured limit),
158
+ including startup, tools, retries, children, compaction and reporting. Per-step
159
+ allowances persist across reassignments; only the user can extend them, including
160
+ in auto mode. Waiting for user answers pauses clocks.
161
+
162
+ The conversation pane groups running agents beneath their current step, for example
139
163
  **Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
140
164
  are excluded. A child finishing does not complete a checklist step.
141
165
 
142
166
  The oracle, agents and children compact at settled turn boundaries before context
143
- reaches 250,000 estimated tokens, or earlier at 80% of the model window or Pi's
167
+ reaches 64,000 estimated tokens, or earlier at 60% of the model window or Pi's
144
168
  native reserve threshold. Recent messages and summaries retain unfinished work;
145
169
  raw session history remains available. A failed summary leaves context intact.
146
- The boundary gives headroom below 300,000 tokens; a single very large input or tool
147
- result can temporarily exceed it before the next boundary.
170
+ Checkpoints preserve constraints, decisions, changed files, verification, failed
171
+ approaches, and next actions. A single large input can exceed the threshold before
172
+ the next settled boundary.
148
173
 
149
174
  Pi's native MCP, `tool_search` and `codemode` are available. **MCP tools** in each
150
175
  agent's Settings entry grants exact tool names, one per line, such as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.36",
3
+ "version": "0.6.38",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/prompts/master.md CHANGED
@@ -282,6 +282,13 @@ Every delegation costs a full agent run, so keep the loop short:
282
282
 
283
283
  ## Time budget
284
284
 
285
+ Every assignment has at most 15 minutes total, or its shorter configured limit,
286
+ including retries, children, tools, compaction and reporting. Step allowances
287
+ persist across reassignments: name the plan step explicitly, and do not reword
288
+ or split an exhausted step to reset its clock. Only the user can extend it,
289
+ including in auto mode. Return an unfinished report when time is spent; stop
290
+ repeating failed approaches. Waiting for the user pauses clocks.
291
+
285
292
  When the task has a time budget (your context says how much is used and
286
293
  left), it covers everyone: you, scouts, workers and the QA gate. The clock
287
294
  runs while you work and stops while you wait on the user.
@@ -348,6 +355,31 @@ the relevant knowledge files: omit `taskId` to update project knowledge without
348
355
  creating or advancing a task. Answer from saved knowledge first, cite code
349
356
  evidence for newly learned facts, and never record a guess.
350
357
 
358
+ ## Scripts
359
+
360
+ The project keeps a shared scripts library that every agent can run; your
361
+ prompt lists it under Scripts, and each agent is shown the scripts that fit its
362
+ step. A script saves the tokens and time of working the same job out again, so
363
+ you decide when work becomes one.
364
+
365
+ - Make a script of a job that will come back in much the same form: seeding or
366
+ resetting data, regenerating fixtures or generated files, a migration run per
367
+ environment, a set of checks run before every review, a scaffold. Not of a
368
+ one-off change, and not of a single command anyone would type.
369
+ - When a step is such a job, say so in its brief: have the worker write the
370
+ step as a script, drafted in the task's directory under `scripts/`, and run
371
+ it. Then save it with `action=script`, `name`, `purpose`, `usage` and
372
+ `file` (the draft), or `text` (the script itself). Read it first: it runs
373
+ for every agent from then on.
374
+ - Name it for what it does, in snake_case, with its language's extension:
375
+ `seed_public_holidays.py`, `run_changed_tests.sh`. Prefer the project's own
376
+ language and runtime. Arguments over hard-coded values, so it serves the
377
+ next case too; and it says what it did, and fails loudly.
378
+ - Before you brief a step, look for a script that does it, and name it in the
379
+ brief. To improve one, save it again under the same name; the old version is
380
+ archived.
381
+ - Workers may propose scripts; take one only when it meets the bar above.
382
+
351
383
  ## Review
352
384
 
353
385
  The repository state is the source of truth; do not blindly trust Scout or
package/prompts/worker.md CHANGED
@@ -113,3 +113,14 @@ then add a `## Pushback` block to your output:
113
113
 
114
114
  The engine records the pushback and blocks that domain until the Master
115
115
  resolves it, so be specific and keep working on the rest of the task.
116
+
117
+ ## Shared scripts
118
+
119
+ Your prompt's Scripts section lists the shared scripts that fit your step. Run
120
+ one instead of doing its work by hand, and check its output as you would your
121
+ own; a script that does not do what it says is a finding for `## Notes`. Do not
122
+ edit the library yourself. When your brief asks you to write a step as a
123
+ script, or you wrote one for a job that will clearly come back, draft it in the
124
+ folder your Workflow Context names for script drafts, named for what it does in snake_case
125
+ (`seed_public_holidays.py`), run it, and propose it under `## Script
126
+ Proposals`.
@@ -30,9 +30,9 @@ export interface RunProfileLike {
30
30
  }
31
31
 
32
32
  export interface EffortRoute extends RunProfileLike {
33
- level: "trivial" | "simple";
33
+ level: EffortLevel;
34
34
  confidence: number;
35
- /** The configured profile the route came down from. */
35
+ /** The configured profile before routing. */
36
36
  from: RunProfileLike;
37
37
  }
38
38
 
@@ -109,6 +109,8 @@ export interface EffortScore {
109
109
 
110
110
  /** Lowers the model or thinking of a step the classifier judges simple or trivial. */
111
111
  export interface EffortRouter {
112
+ /** Children may raise reasoning for complex work, using the parent's configured model. */
113
+ childRoute?(instruction: string, profile: RunProfileLike, options?: { context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
112
114
  /** Score a step and plan its route in one go. */
113
115
  route(instruction: string, profile: RunProfileLike, options?: { thinkingFixed?: boolean; context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined>;
114
116
  /** Score a step once (several agents may share it)… */
@@ -119,6 +121,16 @@ export interface EffortRouter {
119
121
 
120
122
  export function effortRouter(classifier: Classifier, deps: { clamp?: RouteOptions["clamp"]; log?: (text: string) => void } = {}): EffortRouter {
121
123
  const router: EffortRouter = {
124
+ async childRoute(instruction, profile, options = {}) {
125
+ const scored = await router.score(instruction, options);
126
+ if (!scored) return undefined;
127
+ if (scored.level !== "complex" || scored.confidence < classifier.config.thresholds.simpleAt) return router.plan(scored, profile);
128
+ const thinking = profile.model && deps.clamp ? deps.clamp(profile.model, "max") : rank(profile.thinking) > rank("high") ? profile.thinking : "high";
129
+ if (thinking === profile.thinking) return undefined;
130
+ const route: EffortRoute = { ...profile, thinking, level: scored.level, confidence: scored.confidence, from: { ...profile } };
131
+ deps.log?.(`routed child ${routeLabel(route)}`);
132
+ return route;
133
+ },
122
134
  async score(instruction, options = {}) {
123
135
  if (!classifier.enabled("effort")) return undefined;
124
136
  return scoreEffort(classifier, instruction, options.context, options.signal);
@@ -9,6 +9,8 @@ import { appendMetrics } from "../state/metrics.ts";
9
9
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
10
10
  import { isSubagentProcess } from "../pi/quiet.ts";
11
11
  import { Classifier } from "./classifier.ts";
12
+ import { scriptPicker } from "./scripts.ts";
13
+ import type { ScriptPicker } from "../scripts/pick.ts";
12
14
  import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
13
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
14
16
  import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
@@ -46,9 +48,15 @@ export function hintsFor(scope: FileScope): FileHinter {
46
48
  return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
47
49
  }
48
50
 
49
- /** Relevant knowledge for agents, with what Jev kept of each long file logged to the lobby's activity feed. */
51
+ /** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
50
52
  export function knowledgeFor(): KnowledgePicker {
51
- return knowledgePicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
53
+ const log = (text: string) => lobbyFeed.log("CLASSIFIER", text, "info");
54
+ return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
55
+ }
56
+
57
+ /** Shared scripts for a quick fix, with what Jev matched logged to the lobby's activity feed. */
58
+ export function scriptsPickerFor(): ScriptPicker {
59
+ return scriptPicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
52
60
  }
53
61
 
54
62
  /** Triage a request for a task in a tree, logged to the lobby's activity feed; undefined when triage is off or fails. */
@@ -16,6 +16,7 @@ import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, ty
16
16
  import type { Classifier } from "./classifier.ts";
17
17
  import { noul, yesOf, type SystemOneRequest } from "./client.ts";
18
18
  import { clip } from "./limits.ts";
19
+ import type { ScriptPicker } from "../scripts/pick.ts";
19
20
 
20
21
  /** How many characters of each knowledge file a prompt has room for. */
21
22
  export const KNOWLEDGE_BUDGET_CHARS = 4000;
@@ -27,7 +28,7 @@ export const MAX_SECTIONS = 60;
27
28
  export const KNOWLEDGE_BUDGET_MS = 1500;
28
29
 
29
30
  /** The three files a prompt carries: `knowledge`, `standards` and `decisions`. */
30
- export type KnowledgeSlice = keyof KnowledgeSelection;
31
+ export type KnowledgeSlice = Exclude<keyof KnowledgeSelection, "scripts">;
31
32
  export type KnowledgePaths = Partial<Record<KnowledgeSlice, string>>;
32
33
 
33
34
  export interface SectionCall {
@@ -100,9 +101,11 @@ export function leftOutNote(kept: Kept, name: string, path?: string): string {
100
101
  : `_Kept ${kept.kept} of ${kept.total} sections of ${name} for this step.${where}_`;
101
102
  }
102
103
 
103
- /** Picks what an agent's prompt carries of its knowledge files. */
104
+ /** Picks what an agent's prompt carries of its knowledge files, and of the shared scripts. */
104
105
  export interface KnowledgePicker {
105
106
  select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
107
+ /** Which shared scripts fit a step; shared words when absent. */
108
+ scripts?: ScriptPicker;
106
109
  }
107
110
 
108
111
  const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared scripts for a step. Jev reads each script's name and purpose against
3
+ * the step (one yes/no per script) and the ones that do part of it are put in
4
+ * the agent's prompt with how to run them, so the agent runs the script rather
5
+ * than working the job out again. A library longer than Jev is asked about is
6
+ * narrowed by shared words first. Off, failed or out of time, shared words
7
+ * decide (`scripts/pick.ts`).
8
+ */
9
+ import type { Classifier } from "./classifier.ts";
10
+ import { noul, yesOf, type SystemOneRequest } from "./client.ts";
11
+ import { clip } from "./limits.ts";
12
+ import { score, tokenize } from "../knowledge/selector.ts";
13
+ import { keywordScripts, MAX_SUGGESTED, type ScriptPicker } from "../scripts/pick.ts";
14
+ import { nameWords, type ScriptEntry } from "../scripts/library.ts";
15
+
16
+ /** Scripts Jev is asked about for one step. */
17
+ export const MAX_CANDIDATES = 40;
18
+ /** The step waits this long for Jev, then goes with shared words. */
19
+ export const SCRIPTS_BUDGET_MS = 1500;
20
+
21
+ export interface ScriptCall {
22
+ request: SystemOneRequest;
23
+ /** The script each question key stands for. */
24
+ keys: Map<string, ScriptEntry>;
25
+ }
26
+
27
+ /** The scripts to ask about: all of them, or the `MAX_CANDIDATES` that share the most words with the step. */
28
+ export function candidateScripts(step: string, entries: readonly ScriptEntry[]): ScriptEntry[] {
29
+ if (entries.length <= MAX_CANDIDATES) return [...entries];
30
+ const tokens = tokenize(step);
31
+ return entries
32
+ .map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
33
+ .sort((a, b) => b.hits - a.hits || a.index - b.index)
34
+ .slice(0, MAX_CANDIDATES)
35
+ .map((scored) => scored.entry);
36
+ }
37
+
38
+ /** One request that asks, for each script, whether it does part of the step. */
39
+ export function scriptRequest(step: string, entries: readonly ScriptEntry[]): ScriptCall {
40
+ const scripts: Record<string, string> = {};
41
+ const questions: SystemOneRequest["questions"] = {};
42
+ const keys = new Map<string, ScriptEntry>();
43
+ entries.forEach((entry, position) => {
44
+ const key = `k${position + 1}`;
45
+ scripts[key] = clip(`${entry.name}: ${entry.purpose}`, 300);
46
+ questions[key] = noul(`Does the script \`scripts.${key}\` (its file name and purpose) do all or part of \`task\`, so an agent doing the task should run it instead of doing that work by hand? Yes when running it is a step the task needs; no when it is about something else or only shares words with it.`);
47
+ keys.set(key, entry);
48
+ });
49
+ return { request: { state: { task: clip(step, 3000), scripts }, questions }, keys };
50
+ }
51
+
52
+ /**
53
+ * A picker on Jev: the scripts it judges to do part of the step, most likely
54
+ * first. Shared words decide when it is off or cannot judge.
55
+ */
56
+ export function scriptPicker(classifier: Classifier, log?: (text: string) => void): ScriptPicker {
57
+ return {
58
+ async pick(step, entries, options = {}) {
59
+ if (entries.length === 0) return [];
60
+ if (!step.trim() || !classifier.enabled("scripts")) return keywordScripts(step, entries);
61
+ const call = scriptRequest(step, candidateScripts(step, entries));
62
+ const result = await classifier.ask("scripts", call.request, { ...(options.signal ? { signal: options.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, SCRIPTS_BUDGET_MS) });
63
+ if (!result) return keywordScripts(step, entries);
64
+ const judged = [...call.keys].map(([key, entry]) => ({ entry, yes: yesOf(result.answers, key) }));
65
+ // An answer to none of the questions says nothing: shared words decide.
66
+ if (judged.every((entry) => entry.yes === undefined)) return keywordScripts(step, entries);
67
+ const picked = judged
68
+ .filter((entry) => (entry.yes ?? 0) >= classifier.config.thresholds.scriptRelevantAt)
69
+ .sort((a, b) => b.yes! - a.yes!)
70
+ .slice(0, MAX_SUGGESTED)
71
+ .map((entry) => entry.entry);
72
+ log?.(`scripts: ${picked.length > 0 ? picked.map((entry) => entry.name).join(", ") : "none"} fit the step (of ${entries.length}) · ${result.ms} ms`);
73
+ return picked;
74
+ },
75
+ };
76
+ }
@@ -8,6 +8,8 @@ import { EditLog } from "../state/changes.ts";
8
8
  import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
9
  import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
10
  import { ASK_ENV } from "../ask/relay.ts";
11
+ import { STEP_LIMIT_MS } from "../state/step-limits.ts";
12
+ import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
11
13
  import { ASK_TOOL } from "../ask/types.ts";
12
14
  import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
13
15
 
@@ -16,13 +18,15 @@ export interface AgentContext {
16
18
  standards?: string;
17
19
  knowledge?: string;
18
20
  decisions?: string;
21
+ /** The shared scripts that fit the step. */
22
+ scripts?: string;
19
23
  /** Per-agent custom instructions from the global config. */
20
24
  instructions?: string;
21
25
  workflowContext?: string;
22
26
  }
23
27
 
24
28
  /** Live fields a caller (the file desk) may set on a running agent. */
25
- export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor">;
29
+ export type LivePatch = Pick<AgentRun, "note" | "noteKind" | "waitingFor" | "model" | "thinking" | "route" | "routedFrom">;
26
30
 
27
31
  /** Control over one running attempt, handed to `onStart`. */
28
32
  export interface AgentHandle {
@@ -35,6 +39,8 @@ export interface AgentRequest {
35
39
  taskId: string;
36
40
  domain: Domain;
37
41
  role: Role;
42
+ effort?: EffortRouter;
43
+ effortProfile?: RunProfileLike;
38
44
  /** Concrete instruction sent as the subagent's task message. */
39
45
  instruction: string;
40
46
  /** Prompt layers selected for this run. */
@@ -78,6 +84,10 @@ export interface AgentRequest {
78
84
  * (the engine asking the user) grants more, and the same agent carries on.
79
85
  */
80
86
  export interface AgentTime {
87
+ /** Active time shared across attempts and model fallbacks. */
88
+ usedMs?: number;
89
+ /** Include reporting in the hard allowance. */
90
+ strict?: boolean;
81
91
  /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
82
92
  endsAt: number;
83
93
  /** The allotment, as the agent and the lobby see it. */
@@ -108,6 +118,11 @@ export function carryOnMessage(extraMs: number): string {
108
118
  /** Least time an attempt starts with; a retry with less than this left is not started. */
109
119
  const MIN_ATTEMPT_MS = 60_000;
110
120
 
121
+ export function boundedAgentTime(timeoutMs: number): AgentTime {
122
+ const allotMs = Math.min(STEP_LIMIT_MS, timeoutMs);
123
+ return { endsAt: Date.now() + allotMs, allotMs, strict: true };
124
+ }
125
+
111
126
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
112
127
  export function watchdogOptions(workflow: {
113
128
  maxAgentRetries: number;
@@ -137,7 +152,19 @@ function retryable(run: AgentRun): boolean {
137
152
  * and a slow agent cannot double its time budget.
138
153
  */
139
154
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
155
+ request.time ??= boundedAgentTime(request.timeoutMs);
156
+ if (!request.time.strict) {
157
+ request.time.endsAt = Math.min(request.time.endsAt, Date.now() + Math.min(STEP_LIMIT_MS, request.timeoutMs));
158
+ request.time.allotMs = Math.min(request.time.allotMs, STEP_LIMIT_MS, request.timeoutMs);
159
+ request.time.strict = true;
160
+ }
161
+ request.time.usedMs ??= Math.max(0, request.time.allotMs - (request.time.endsAt - Date.now()));
140
162
  const startedAt = new Date().toISOString();
163
+ if (request.time.endsAt <= Date.now()) {
164
+ 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 };
165
+ request.onUpdate?.(stopped);
166
+ return stopped;
167
+ }
141
168
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
142
169
  // A failed attempt may have edited files before the retry: the run owns every edit.
143
170
  const edits = new EditLog(request.cwd);
@@ -193,21 +220,36 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
193
220
  async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
194
221
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
195
222
  request.onUpdate?.(base);
196
- const live = createLiveRun(base, request, edits);
223
+ const time = request.time!;
224
+ const attemptStart = Date.now();
225
+ let pausedMs = 0;
226
+ let asking = 0;
227
+ let askStarted = 0;
228
+ let expired = false;
229
+ const elapsed = () => Math.max(0, Date.now() - attemptStart - pausedMs - (asking ? Date.now() - askStarted : 0));
230
+ const live = createLiveRun(base, { ...request, onUpdate: (update) => request.onUpdate?.({ ...update, activeMs: (time.usedMs ?? 0) + elapsed(), endsAt: time.endsAt }) }, edits);
197
231
 
198
232
  const controller = new AbortController();
199
233
  activeControllers.add(controller);
200
234
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
235
+ let deadline: ReturnType<typeof setTimeout>;
236
+ const arm = () => {
237
+ clearTimeout(deadline);
238
+ deadline = setTimeout(() => { expired = true; controller.abort(new Error("Assignment time limit reached")); }, Math.max(0, time.endsAt - Date.now()));
239
+ };
240
+ arm();
201
241
  try {
202
242
  const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
203
- const time = request.time;
204
- const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
205
- const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
243
+ const left = Math.max(1, time.endsAt - Date.now());
244
+ const reportMs = Math.min(REPORT_GRACE_MS, Math.floor(left / 4));
245
+ const wrapUp = Math.floor(left * (request.wrapUpAt || 0.75));
206
246
  const onTimeUp = time?.onTimeUp;
207
247
  const result = await runPiAgent({
208
248
  cwd: request.cwd,
209
249
  agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
210
250
  delegationQuota: quota,
251
+ effort: request.effort,
252
+ effortProfile: request.effortProfile,
211
253
  onChildRun: (child) => {
212
254
  for (const path of child.edited ?? []) edits.note("edit", { path });
213
255
  request.onUpdate?.(child);
@@ -218,21 +260,28 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
218
260
  tools: toolsFor(request),
219
261
  model: request.model,
220
262
  thinking: request.thinking,
221
- timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
263
+ timeoutMs: left,
222
264
  ...(time ? {
223
265
  time: {
224
- upAtMs: left,
266
+ hardLimit: true,
267
+ upAtMs: Math.max(1, left - reportMs),
225
268
  headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
226
269
  headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
227
- upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
228
- graceMs: REPORT_GRACE_MS,
270
+ 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." : ""}`,
271
+ graceMs: reportMs,
229
272
  ...(onTimeUp ? {
230
273
  onTimeUp: async (report: string) => {
231
- const extraMs = await onTimeUp(live.current(), report);
274
+ clearTimeout(deadline);
275
+ const waiting = Date.now();
276
+ let extraMs: number;
277
+ try { extraMs = await onTimeUp(live.current(), report); }
278
+ finally { pausedMs += Date.now() - waiting; }
279
+ time.endsAt += Date.now() - waiting;
232
280
  if (extraMs > 0) {
233
281
  time.endsAt = Date.now() + extraMs;
234
282
  live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
235
283
  }
284
+ arm();
236
285
  return { extraMs, message: carryOnMessage(extraMs) };
237
286
  },
238
287
  } : {}),
@@ -245,30 +294,42 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
245
294
  stallTimeoutMs: request.stallTimeoutMs,
246
295
  toolStallTimeoutMs: request.toolStallTimeoutMs,
247
296
  wrapUpAtMs: wrapUp,
248
- onEvent: live.onEvent,
297
+ onEvent: (event) => {
298
+ if (event.type === "asking") { if (!asking) askStarted = Date.now(); asking += 1; clearTimeout(deadline); }
299
+ if (event.type === "answered") { asking = 0; pausedMs += event.waitedMs; }
300
+ live.onEvent(event);
301
+ if (event.type === "answered" && asking === 0) arm();
302
+ },
249
303
  onStart: (handle) => request.onStart?.({ runId: base.runId, steer: handle.steer, annotate: live.annotate }),
250
304
  }, run);
251
305
  const current = live.current();
252
306
  const final: AgentRun = {
253
307
  ...base,
254
- status: result.status,
308
+ status: expired ? "timeout" : result.status,
255
309
  output: result.output,
256
- error: result.error ? describeError(result.error, current, result.stalled === true) : undefined,
310
+ error: expired ? describeError("hit the shared assignment time limit", current, false) : result.error ? describeError(result.error, current, result.stalled === true) : undefined,
257
311
  usage: result.usage,
258
312
  finishedAt: new Date().toISOString(),
313
+ activeMs: (time.usedMs ?? 0) + elapsed(),
259
314
  turns: current.turns ?? (result.usage.turns || undefined),
260
315
  tools: current.tools,
261
316
  model: result.model ?? current.model,
262
317
  ...(result.stalled ? { stalled: true } : {}),
263
318
  ...(result.wrappedUp ? { wrappedUp: true } : {}),
264
- ...(result.timeUp ? { timeUp: true } : {}),
319
+ ...(result.timeUp || expired ? { timeUp: true } : {}),
265
320
  ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
266
321
  note: undefined,
267
322
  noteKind: undefined,
268
323
  };
269
324
  request.onUpdate?.(final);
270
325
  return final;
326
+ } catch (error) {
327
+ 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 } : {}) };
328
+ request.onUpdate?.(final);
329
+ return final;
271
330
  } finally {
331
+ clearTimeout(deadline!);
332
+ time.usedMs = (time.usedMs ?? 0) + elapsed();
272
333
  activeControllers.delete(controller);
273
334
  }
274
335
  }
@@ -6,6 +6,8 @@ import { join } from "node:path";
6
6
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
7
7
  import type { AgentRun } from "../schemas/findings.ts";
8
8
  import type { Domain, Role } from "../schemas/agent.ts";
9
+ import { effortFor } from "../classifier/instance.ts";
10
+ import { fellShort, profileLabel, routeLabel } from "../classifier/effort.ts";
9
11
  import { agentProfile } from "../schemas/configuration.ts";
10
12
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
11
13
  import { EditLog } from "../state/changes.ts";
@@ -108,17 +110,26 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
108
110
 
109
111
  const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
110
112
  const childId = randomUUID();
111
- const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
113
+ const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", endsAt: deadlineAt, output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
112
114
  const edits = new EditLog(options.cwd);
113
115
  const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
114
116
  update(base);
115
117
  try {
116
- const left = deadlineAt - Date.now();
117
- if (left <= 0) throw new Error("The parent's time is up.");
118
- const result = await runPiAgent({
118
+ const configured = options.effortProfile ?? { model: options.model, thinking: options.thinking ?? "medium" };
119
+ const effort = options.effort ?? effortFor();
120
+ const route = await effort.childRoute?.(brief, configured, { context: options.task, signal }).catch(() => undefined);
121
+ const attempt = async (model: string | undefined, thinking: string) => {
122
+ const left = deadlineAt - Date.now();
123
+ signal.throwIfAborted();
124
+ if (left <= 0) throw new Error("The parent's time is up.");
125
+ const routed = route && model === route.model && thinking === route.thinking;
126
+ live.annotate({ model, thinking, route: routed ? routeLabel(route) : undefined, routedFrom: routed ? profileLabel(route.from) : undefined, note: routed ? routeLabel(route) : undefined, noteKind: "info" });
127
+ return runPiAgent({
119
128
  ...options,
120
129
  agent: { ...agent, runId: childId, depth: 2 },
121
130
  task: brief,
131
+ model,
132
+ thinking,
122
133
  tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
123
134
  mcpTools,
124
135
  timeoutMs: left,
@@ -131,11 +142,18 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
131
142
  env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
132
143
  onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
133
144
  }, run);
134
- const final: AgentRun = { ...live.current(), ...result, edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
145
+ };
146
+ let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
147
+ let retried = false;
148
+ if (route && (route.level === "simple" || route.level === "trivial") && fellShort(result) && !signal.aborted && deadlineAt - Date.now() >= 60_000) {
149
+ retried = true;
150
+ result = await attempt(configured.model, configured.thinking);
151
+ }
152
+ const final: AgentRun = { ...live.current(), ...result, thinking: retried ? configured.thinking : route?.thinking ?? configured.thinking, attempts: retried ? 2 : 1, ...(route && !retried ? { route: routeLabel(route), routedFrom: profileLabel(route.from) } : {}), edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
135
153
  update(final);
136
154
  return final;
137
155
  } catch (error) {
138
- const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString() };
156
+ const final: AgentRun = { ...live.current(), edited: edits.list(), status: Date.now() >= deadlineAt ? "timeout" : signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
139
157
  update(final);
140
158
  return final;
141
159
  } finally {
@@ -173,7 +191,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
173
191
  return {
174
192
  tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
175
193
  env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
176
- note: `${canDelegate(agent, quota.used) ? `Begin every step by delegating your first two sub-briefs via ${DELEGATE_TOOL} — split the work into two disjoint pieces with separate files. If you need more brainpower, delegate more, 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 ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
194
+ note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
177
195
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
178
196
  desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
179
197
  options.onStart?.(handle);
@@ -11,6 +11,7 @@ import { shortDuration } from "../text.ts";
11
11
  import { readRelayRequest } from "../ask/relay.ts";
12
12
  import type { AskQuestion, AskResult } from "../ask/types.ts";
13
13
  import { grantEnv, grantNote, type Grant } from "../excalidraw/sessions.ts";
14
+ import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
14
15
 
15
16
  /** Live control over one running subagent: queue a steering message for its next turn. */
16
17
  export interface RunHandle {
@@ -24,6 +25,8 @@ export interface RunHandle {
24
25
  export type RelayAsk = (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>;
25
26
 
26
27
  export interface PiRunOptions {
28
+ effort?: EffortRouter;
29
+ effortProfile?: RunProfileLike;
27
30
  agent?: { runId: string; taskId: string; domain: Domain; role: Role; depth?: number };
28
31
  mcpTools?: string[];
29
32
  delegationQuota?: { used: number };
@@ -67,6 +70,8 @@ export interface PiRunOptions {
67
70
  * the decision is pending, and move out with any time granted.
68
71
  */
69
72
  export interface TimeLimit {
73
+ /** Reporting time is included in the allowance, including extensions. */
74
+ hardLimit?: boolean;
70
75
  upAtMs: number;
71
76
  headsUpAtMs?: number;
72
77
  headsUpMessage?: string;
@@ -584,11 +589,12 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
584
589
  turnDone = false;
585
590
  clockStart = Date.now();
586
591
  lastOutput = clockStart;
587
- upAtMs = extraMs;
592
+ upAtMs = time?.hardLimit ? Math.max(1, extraMs - Math.min(time.graceMs, Math.floor(extraMs / 4))) : extraMs;
588
593
  // The user just named the time it has: no heads-up quoting the old allotment.
589
594
  headsUpAtMs = 0;
590
- deadlineAt = Date.now() + extraMs + (time?.graceMs ?? 0);
591
- deadline = setTimeout(onDeadline, extraMs + (time?.graceMs ?? 0));
595
+ const nextLimit = extraMs + (time?.hardLimit ? 0 : time?.graceMs ?? 0);
596
+ deadlineAt = Date.now() + nextLimit;
597
+ deadline = setTimeout(onDeadline, nextLimit);
592
598
  input.send({ type: "prompt", message: message ?? "You have more time: carry on from where you left off and finish." });
593
599
  options.onEvent?.({ type: "extended", ms: extraMs });
594
600
  }, () => {
@@ -9,6 +9,8 @@ export interface KnowledgeSelection {
9
9
  knowledge: string;
10
10
  standards: string;
11
11
  decisions: string;
12
+ /** The shared scripts that fit the step and how to run them (see `scripts/pick.ts`). */
13
+ scripts?: string;
12
14
  }
13
15
 
14
16
  export interface KnowledgeInput {