@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
@@ -29,17 +29,18 @@ export interface AgentModelConfig {
29
29
  instructions?: string;
30
30
  /** Exact native MCP tool names this profile may call; empty grants none. */
31
31
  mcpTools?: string[];
32
- /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
33
- timeoutMs?: number;
32
+ /** Names of the skills this agent may load; absent gives it every skill pi finds, empty gives it none. */
33
+ skills?: string[];
34
34
  /** Compact this agent's context on its own as it fills (on unless false); the oracle's also updates the knowledge base. */
35
35
  autoCompact?: boolean;
36
36
  }
37
37
 
38
- /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
38
+ /** Scouts pick a model only; their thinking is fixed at `SCOUT_THINKING`. */
39
39
  export interface ScoutConfig {
40
40
  mcpTools?: string[];
41
+ /** See AgentModelConfig.skills. */
42
+ skills?: string[];
41
43
  model: ModelRef;
42
- timeoutMs: number;
43
44
  /** Tool calls a scout may make: it is told to wrap up at 60% and refused more past this, so it reports with what it has; 0 = no limit. */
44
45
  maxToolCalls: number;
45
46
  /** Compact a scout's context on its own as it fills (on unless false). */
@@ -74,21 +75,16 @@ export interface WorkflowConfig {
74
75
  requireApprovalForFeatures: boolean;
75
76
  requireApprovalForDependencies: boolean;
76
77
  requireApprovalForArchitectureChanges: boolean;
77
- agentTimeoutMs: number;
78
- /** Bounded retries for transient agent failures (crash/stall), §59. A spent deadline never retries. */
78
+ /** Bounded retries for transient agent failures (crash/stall), §59. A cancel never retries. */
79
79
  maxAgentRetries: number;
80
80
  /** Kill a subagent after this long without any output; 0 disables. */
81
81
  stallTimeoutMs: number;
82
82
  /** Silence allowed while a single tool call runs (tests, builds); 0 disables. */
83
83
  toolStallTimeoutMs: number;
84
- /** Fraction of the time limit at which an agent is asked to wrap up and report; 0 disables. */
85
- wrapUpAt: number;
86
84
  /** Workers that may run at once when the Master delegates several domains together. */
87
85
  maxParallelWorkers: number;
88
86
  /** The oracle starts each task with a clean context: its model is sent only the conversation since the task started, or since the last one ended. */
89
87
  freshContext: boolean;
90
- /** Minutes of work time a new task gets unless it is started with its own (`--budget`); 0 = no budget. */
91
- taskBudgetMinutes: number;
92
88
  /** Small, clear, low-risk requests take the fast track (no scouts, proposal or plan; QA only when tests are needed); false puts every task on the full workflow. */
93
89
  fastTrack: boolean;
94
90
  /** A delegation that names nothing concrete, or is long without done criteria, is sent back to the oracle once for a fuller brief (sending it again unchanged goes through). */
@@ -101,6 +97,24 @@ export interface WorkflowConfig {
101
97
  repoMap: boolean;
102
98
  /** A scout asked the same thing again for the same task, on a tree that has not changed, reuses its findings instead of running. */
103
99
  reuseScouts: boolean;
100
+ /** How a parallel batch shares the repository: one checkout with the file desk (`desk`), or a worktree per worker whose changes are merged back when the batch ends (`worktrees`). */
101
+ parallelMode: ParallelMode;
102
+ /** The QA gate reviews with a clean context: the request, plan, board contracts and diff, not the workers' own accounts of their work. */
103
+ cleanReview: boolean;
104
+ /** Before a task completes, one read-only agent checks the change against the user's original request (not the plan) and names any gap. */
105
+ goalCheck: boolean;
106
+ /** Workers may ask a stronger model for advice on a hard step (`ask_expert`), a couple of times per step. */
107
+ consultExpert: boolean;
108
+ /** The model `ask_expert` consults; empty uses the oracle's. */
109
+ expertModel: string;
110
+ }
111
+
112
+ /** How workers in one parallel batch share the repository. */
113
+ export const PARALLEL_MODES = ["desk", "worktrees"] as const;
114
+ export type ParallelMode = (typeof PARALLEL_MODES)[number];
115
+
116
+ export function isParallelMode(value: unknown): value is ParallelMode {
117
+ return typeof value === "string" && (PARALLEL_MODES as readonly string[]).includes(value);
104
118
  }
105
119
 
106
120
  /**
@@ -249,7 +263,7 @@ export interface BotLobbyConfig {
249
263
  researcher: AgentModelConfig;
250
264
  /** Direct quick fixes from the lobby: no scouting, planning or review. */
251
265
  quickFix: AgentModelConfig;
252
- /** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
266
+ /** The task planner that grills the user until a plan is clear. */
253
267
  planner: AgentModelConfig;
254
268
  workflow: WorkflowConfig;
255
269
  /** The engine lints the files a task's agents touched, after each step, before QA and at completion. */
@@ -262,34 +276,36 @@ export interface BotLobbyConfig {
262
276
  export const DEFAULT_CONFIG: BotLobbyConfig = {
263
277
  master: { model: INHERIT_MODEL, thinking: "high", instructions: "" },
264
278
  agents: {
265
- designer: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
266
- backend: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
267
- qa: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
279
+ designer: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "" },
280
+ backend: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "" },
281
+ qa: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "" },
268
282
  },
269
- scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000, maxToolCalls: 30 },
270
- researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
271
- quickFix: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
272
- planner: { model: INHERIT_MODEL, thinking: "high", instructions: "", timeoutMs: 5 * 60 * 1000 },
283
+ scout: { model: INHERIT_MODEL, maxToolCalls: 30 },
284
+ researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "" },
285
+ quickFix: { model: INHERIT_MODEL, thinking: "low", instructions: "" },
286
+ planner: { model: INHERIT_MODEL, thinking: "high", instructions: "" },
273
287
  workflow: {
274
288
  maxReviewIterations: 2,
275
289
  maxParallelScouts: 3,
276
290
  requireApprovalForFeatures: true,
277
291
  requireApprovalForDependencies: true,
278
292
  requireApprovalForArchitectureChanges: true,
279
- agentTimeoutMs: 15 * 60 * 1000,
280
293
  maxAgentRetries: 1,
281
294
  stallTimeoutMs: 5 * 60 * 1000,
282
295
  toolStallTimeoutMs: 10 * 60 * 1000,
283
- wrapUpAt: 0.75,
284
296
  maxParallelWorkers: 3,
285
297
  freshContext: true,
286
- taskBudgetMinutes: 0,
287
298
  fastTrack: true,
288
299
  briefCheck: true,
289
300
  routeQuickFixes: true,
290
301
  gitIsolation: "off",
291
302
  repoMap: true,
292
303
  reuseScouts: true,
304
+ parallelMode: "desk",
305
+ cleanReview: true,
306
+ goalCheck: true,
307
+ consultExpert: true,
308
+ expertModel: "",
293
309
  },
294
310
  lint: { mode: "advise", command: "", extensions: [], timeoutMs: 2 * 60 * 1000 },
295
311
  knowledge: {
@@ -345,6 +361,17 @@ function positive(value: unknown): number | undefined {
345
361
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
346
362
  }
347
363
 
364
+ /** Skill names as pi writes them (lowercase letters, digits and hyphens); absent when the value is not a list, so the agent keeps every skill. */
365
+ export function normalizeSkills(value: unknown): string[] | undefined {
366
+ return Array.isArray(value) ? [...new Set(value.filter((name): name is string => typeof name === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(name)))] : undefined;
367
+ }
368
+
369
+ /** `{ skills }` when an entry names its skills, else nothing. */
370
+ function skillsField(value: unknown): { skills?: string[] } {
371
+ const skills = normalizeSkills(value);
372
+ return skills ? { skills } : {};
373
+ }
374
+
348
375
  export function normalizeMcpTools(value: unknown): string[] {
349
376
  return Array.isArray(value) ? [...new Set(value.filter((tool): tool is string => typeof tool === "string" && /^mcp__[A-Za-z0-9_]+__[A-Za-z0-9_]+$/.test(tool)))] : [];
350
377
  }
@@ -353,20 +380,19 @@ export function normalizeMcpTools(value: unknown): string[] {
353
380
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
354
381
  const merged = { ...base, ...(override ?? {}) };
355
382
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
356
- const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
357
383
  const mcpTools = normalizeMcpTools(merged.mcpTools);
358
- return { model: merged.model, thinking, ...(mcpTools.length ? { mcpTools } : {}), ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(timeoutMs ? { timeoutMs } : {}), ...(merged.autoCompact === false ? { autoCompact: false } : {}) };
384
+ return { model: merged.model, thinking, ...(mcpTools.length ? { mcpTools } : {}), ...skillsField(merged.skills), ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(merged.autoCompact === false ? { autoCompact: false } : {}) };
359
385
  }
360
386
 
361
- /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
387
+ /** Scouts keep a model and a tool-call cap; any thinking value in the file is dropped. */
362
388
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
363
389
  const base = DEFAULT_CONFIG.scout;
364
390
  const mcpTools = normalizeMcpTools(override?.mcpTools);
365
391
  return {
366
392
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
367
- timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
368
393
  maxToolCalls: typeof override?.maxToolCalls === "number" && Number.isFinite(override.maxToolCalls) && override.maxToolCalls >= 0 ? Math.round(override.maxToolCalls) : base.maxToolCalls,
369
394
  ...(mcpTools.length ? { mcpTools } : {}),
395
+ ...skillsField(override?.skills),
370
396
  ...(override?.autoCompact === false ? { autoCompact: false } : {}),
371
397
  };
372
398
  }
@@ -480,8 +506,14 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
480
506
  workflow.routeQuickFixes = workflow.routeQuickFixes !== false;
481
507
  workflow.repoMap = workflow.repoMap !== false;
482
508
  workflow.reuseScouts = workflow.reuseScouts !== false;
509
+ workflow.parallelMode = isParallelMode(workflow.parallelMode) ? workflow.parallelMode : DEFAULT_CONFIG.workflow.parallelMode;
510
+ workflow.cleanReview = workflow.cleanReview !== false;
511
+ workflow.goalCheck = workflow.goalCheck !== false;
512
+ workflow.consultExpert = workflow.consultExpert !== false;
513
+ workflow.expertModel = typeof workflow.expertModel === "string" ? workflow.expertModel.trim() : "";
483
514
  workflow.gitIsolation = isGitIsolation(workflow.gitIsolation) ? workflow.gitIsolation : DEFAULT_CONFIG.workflow.gitIsolation;
484
- workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
515
+ // Agent time limits are gone: a config file that still names them is cleaned on its next save.
516
+ for (const key of ["agentTimeoutMs", "wrapUpAt", "taskBudgetMinutes"]) delete (workflow as unknown as Record<string, unknown>)[key];
485
517
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
486
518
  const srcAgents = (src.agents ?? {}) as Partial<BotLobbyConfig["agents"]>;
487
519
  return {
@@ -509,15 +541,16 @@ export function hasScoutThinking(partial: unknown): boolean {
509
541
  return Boolean(scout && typeof scout === "object" && "thinking" in scout);
510
542
  }
511
543
 
512
- /** What one subagent run uses: model (undefined = not configured), thinking and time limit. */
544
+ /** What one subagent run uses: model (undefined = not configured), thinking and grants. */
513
545
  export interface AgentProfile {
514
546
  kind: WorkflowProfileKind;
515
547
  model?: string;
516
548
  thinking: string;
517
- timeoutMs: number;
518
549
  instructions?: string;
519
550
  /** Exact native MCP tool names this profile may call; empty grants none. */
520
551
  mcpTools?: string[];
552
+ /** Skill names it may load; absent keeps every skill pi finds. */
553
+ skills?: string[];
521
554
  /** False when its settings turn auto-compaction off. */
522
555
  autoCompact?: boolean;
523
556
  }
@@ -527,6 +560,11 @@ function compactField(entry: { autoCompact?: boolean }): { autoCompact?: false }
527
560
  return entry.autoCompact === false ? { autoCompact: false } : {};
528
561
  }
529
562
 
563
+ /** The MCP tools and skills an entry grants, leaving out what it does not name. */
564
+ function grantFields(entry: { mcpTools?: string[]; skills?: string[] }): { mcpTools?: string[]; skills?: string[] } {
565
+ return { ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}), ...(entry.skills ? { skills: entry.skills } : {}) };
566
+ }
567
+
530
568
  /** The settings entry a domain/role run draws from. */
531
569
  export function profileKind(domain: Domain, role: Role): WorkflowProfileKind {
532
570
  if (role === "scout") return "scout";
@@ -539,7 +577,7 @@ function modelOf(ref: ModelRef): string | undefined {
539
577
  }
540
578
 
541
579
  /**
542
- * Resolve model, thinking and time limit for one run from settings alone.
580
+ * Resolve model, thinking and grants for one run from settings alone.
543
581
  * Workers and the QA gate use their domain's entry, scouts and researchers
544
582
  * their own; custom instructions always come from the domain, so a designer
545
583
  * scout still carries the designer's house rules.
@@ -547,12 +585,11 @@ function modelOf(ref: ModelRef): string | undefined {
547
585
  export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role): AgentProfile {
548
586
  const kind = profileKind(domain, role);
549
587
  const instructions = config.agents[domain].instructions;
550
- const fallback = config.workflow.agentTimeoutMs;
551
588
  if (kind === "scout") {
552
- return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions, ...(config.scout.mcpTools?.length ? { mcpTools: config.scout.mcpTools } : {}), ...compactField(config.scout) };
589
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, instructions, ...grantFields(config.scout), ...compactField(config.scout) };
553
590
  }
554
591
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
555
- return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}), ...compactField(entry) };
592
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, instructions, ...grantFields(entry), ...compactField(entry) };
556
593
  }
557
594
 
558
595
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -561,22 +598,27 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
561
598
  }
562
599
 
563
600
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
564
- export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string; mcpTools?: string[]; autoCompact?: boolean } {
601
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; instructions?: string; mcpTools?: string[]; skills?: string[]; autoCompact?: boolean } {
565
602
  const entry = lobbyAgentConfig(config, kind);
566
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}), ...compactField(entry) };
603
+ return { model: modelOf(entry.model), thinking: entry.thinking, instructions: entry.instructions, ...grantFields(entry), ...compactField(entry) };
567
604
  }
568
605
 
569
606
  /**
570
607
  * A planning panel seat's profile: the domain's (or the researcher's) model,
571
- * thinking and custom instructions, bounded by the planner's per-turn limit.
608
+ * thinking, custom instructions and grants.
572
609
  */
573
- export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string; mcpTools?: string[]; autoCompact?: boolean } {
610
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; instructions?: string; mcpTools?: string[]; skills?: string[]; autoCompact?: boolean } {
574
611
  const entry = member === "researcher" ? config.researcher : config.agents[member];
575
- const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
576
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}), ...compactField(entry) };
612
+ return { model: modelOf(entry.model), thinking: entry.thinking, instructions: entry.instructions, ...grantFields(entry), ...compactField(entry) };
613
+ }
614
+
615
+ /** The model and thinking a worker's `ask_expert` consults: the configured expert, or the oracle's model. */
616
+ export function expertProfile(config: BotLobbyConfig): { model?: string; thinking: string } {
617
+ const model = config.workflow.expertModel ? modelOf(config.workflow.expertModel) : modelOf(config.master.model);
618
+ return { ...(model ? { model } : {}), thinking: "high" };
577
619
  }
578
620
 
579
- /** Resolves the model, thinking and time limit one subagent run uses. */
621
+ /** Resolves the model, thinking and grants one subagent run uses. */
580
622
  export type ProfileResolver = (domain: Domain, role: Role) => AgentProfile;
581
623
 
582
624
  /** The resolver a request carries, or plain settings when none was supplied (tests, headless use). */
@@ -54,10 +54,6 @@ export interface WorkerResult {
54
54
  domain: Domain;
55
55
  role: "worker";
56
56
  completed: string;
57
- /** Stopped for time: what it was doing and what is still to do. */
58
- leftOff?: string;
59
- /** Stopped for time: the minutes it asks for, and why. */
60
- moreTime?: { minutes?: number; reason: string };
61
57
  filesChanged: FileChange[];
62
58
  verification: string;
63
59
  notes: string;
@@ -157,8 +153,6 @@ export interface AgentRun {
157
153
  thinking?: string;
158
154
  /** Killed by the stall watchdog after going silent. */
159
155
  stalled?: boolean;
160
- /** Asked to wrap up before its deadline; the report may be partial. */
161
- wrappedUp?: boolean;
162
156
  /** File this worker is queued for at the file desk. */
163
157
  waitingFor?: string;
164
158
  /** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
@@ -167,10 +161,4 @@ export interface AgentRun {
167
161
  route?: string;
168
162
  /** Files the run changed with `edit`/`write`, absolute, across its attempts. */
169
163
  edited?: string[];
170
- /** Under a task time budget: the time it was given, when that runs out (epoch ms), and any granted since. */
171
- allotMs?: number;
172
- endsAt?: number;
173
- extendedMs?: number;
174
- /** Stopped at its allotted time and not given more: its report says where it left off. */
175
- timeUp?: boolean;
176
164
  }
@@ -95,7 +95,6 @@ export interface RunLogEntry {
95
95
  cost?: number;
96
96
  attempts?: number;
97
97
  stalled?: boolean;
98
- wrappedUp?: boolean;
99
98
  error?: string;
100
99
  /** The classifier routed this run down from this configured profile. */
101
100
  routedFrom?: string;
@@ -279,6 +278,18 @@ export interface Task {
279
278
  lintWaiver?: { at: string };
280
279
  /** Plan steps the user skipped, each with their reason; the oracle is told not to work on them. */
281
280
  skippedSteps?: SkippedStep[];
281
+ /** The last goal check: the change (by its diff's hash) read against the user's request before completion. */
282
+ goalCheck?: GoalCheckRecord;
283
+ }
284
+
285
+ export interface GoalCheckRecord {
286
+ /** Hash of the diff it read: the same change is not checked twice. */
287
+ key: string;
288
+ verdict: "met" | "not_met";
289
+ gaps: string[];
290
+ at: string;
291
+ /** When completion was refused for these gaps: only then can the oracle complete anyway with a reason. */
292
+ refusedAt?: string;
282
293
  }
283
294
 
284
295
  export function createTask(
@@ -272,7 +272,7 @@ export interface MetricGroup {
272
272
  kinds: MetricKind[];
273
273
  runs: number;
274
274
  successes: number;
275
- /** Runs that stalled or hit their time limit. */
275
+ /** Runs that stalled. */
276
276
  timeouts: number;
277
277
  avgMs: number;
278
278
  p50Ms: number;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A task's work clock. Work time counts while the oracle works on the task —
3
+ * its own turns, which include every agent it runs — and not while it waits
4
+ * on the user (between turns, and while a dialog is open). It is saved beside
5
+ * the task's state as work.json (see work-time.ts) by the session that owns
6
+ * the task. Nothing here limits how long an agent or a task may run.
7
+ */
8
+ import { clearStaleRuns, updateWork } from "./work-time.ts";
9
+
10
+ /** `34m`, `1h 05m`. */
11
+ export function formatMinutes(ms: number): string {
12
+ const minutes = Math.max(0, Math.round(ms / 60_000));
13
+ if (minutes < 60) return `${minutes}m`;
14
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
15
+ }
16
+
17
+ /* ------------------------------------------------------------ work clock */
18
+ /*
19
+ * Every task the session drives has a work clock: it keeps the task's work
20
+ * time (work.json, what the lobby shows as "worked"). It measures; it never
21
+ * limits how long anything runs.
22
+ */
23
+
24
+ interface Clock {
25
+ root: string;
26
+ configDir: string;
27
+ /** The oracle's turn on the task is running. */
28
+ running: boolean;
29
+ /** Dialogs open: the user is being asked, so the clock waits. */
30
+ waits: number;
31
+ /** Since when the current stretch counts, while it does. */
32
+ since?: number;
33
+ }
34
+
35
+ /** Work clocks of the tasks this process drives. */
36
+ const clocks = new Map<string, Clock>();
37
+ /** How often a running clock is saved, so other windows see time pass (and see it is still kept). */
38
+ export const CLOCK_FLUSH_MS = 30_000;
39
+ let flusher: ReturnType<typeof setInterval> | undefined;
40
+ /** The oracle's turn is running in this process. */
41
+ let turn = false;
42
+
43
+ /** Fold the stretch that counted into the saved totals, and start a new one if it still counts. */
44
+ function settle(taskId: string, clock: Clock, now = Date.now()): void {
45
+ let spent = 0;
46
+ if (clock.since !== undefined) {
47
+ spent = Math.max(0, now - clock.since);
48
+ clock.since = undefined;
49
+ }
50
+ if (clock.running && clock.waits === 0) clock.since = now;
51
+ const since = clock.since;
52
+ updateWork(clock.root, clock.configDir, taskId, (work) => {
53
+ work.workedMs += spent;
54
+ if (since !== undefined) work.runningSince = new Date(since).toISOString();
55
+ else delete work.runningSince;
56
+ }, new Date(now));
57
+ }
58
+
59
+ function ensureFlusher(): void {
60
+ if (flusher) return;
61
+ flusher = setInterval(() => {
62
+ for (const [taskId, clock] of clocks) if (clock.running) settle(taskId, clock);
63
+ }, CLOCK_FLUSH_MS);
64
+ flusher.unref();
65
+ }
66
+
67
+ /** The oracle's turn started: a task it drives, or starts within the turn, is being worked on. */
68
+ export function turnStarted(): void {
69
+ turn = true;
70
+ }
71
+
72
+ /** The oracle started a turn on a task (or took one up within it): its clock runs. */
73
+ export function startClock(root: string, configDir: string, taskId: string): void {
74
+ let clock = clocks.get(taskId);
75
+ if (!clock) {
76
+ clock = { root, configDir, running: false, waits: 0 };
77
+ clocks.set(taskId, clock);
78
+ clearStaleRuns(root, configDir, taskId);
79
+ }
80
+ if (clock.running) return;
81
+ clock.running = true;
82
+ settle(taskId, clock);
83
+ ensureFlusher();
84
+ }
85
+
86
+ /**
87
+ * The task the session drives now, after a step that may have started, ended or taken over one: while
88
+ * a turn runs, its clock runs and any other task's stops.
89
+ */
90
+ export function clockTask(root: string, configDir: string, taskId: string | undefined): void {
91
+ for (const [id, clock] of clocks) {
92
+ if (id === taskId || !clock.running) continue;
93
+ clock.running = false;
94
+ settle(id, clock);
95
+ }
96
+ if (taskId && turn) startClock(root, configDir, taskId);
97
+ }
98
+
99
+ /** The oracle's turn ended: the clocks stop until the next one. */
100
+ export function stopClocks(): void {
101
+ turn = false;
102
+ for (const [taskId, clock] of clocks) {
103
+ if (!clock.running && clock.since === undefined) continue;
104
+ clock.running = false;
105
+ settle(taskId, clock);
106
+ }
107
+ }
108
+
109
+ /** The user is being asked: every clock waits (nested dialogs each call this and `resumeClocks`). */
110
+ export function pauseClocks(): void {
111
+ for (const [taskId, clock] of clocks) {
112
+ clock.waits += 1;
113
+ settle(taskId, clock);
114
+ }
115
+ }
116
+
117
+ export function resumeClocks(): void {
118
+ for (const [taskId, clock] of clocks) {
119
+ clock.waits = Math.max(0, clock.waits - 1);
120
+ settle(taskId, clock);
121
+ }
122
+ }
123
+
124
+ /** Run `ask` (a dialog) with every clock stopped: time spent waiting on the user is not the task's. */
125
+ export async function whileAsking<T>(ask: () => Promise<T>): Promise<T> {
126
+ pauseClocks();
127
+ try {
128
+ return await ask();
129
+ } finally {
130
+ resumeClocks();
131
+ }
132
+ }
133
+
134
+ /** Forget the clocks (tests; a session that ends). */
135
+ export function resetClocks(): void {
136
+ clocks.clear();
137
+ turn = false;
138
+ if (flusher) clearInterval(flusher);
139
+ flusher = undefined;
140
+ }
@@ -2,7 +2,7 @@
2
2
  * How long a task's agents have actually worked on it, and which worker runs
3
3
  * are under way. Work time is the oracle's turns on the task — which include
4
4
  * every agent it runs — and not the time it waits on the user (between turns,
5
- * and while a dialog is open); the work clock in budget.ts measures it. The
5
+ * and while a dialog is open); the work clock in work-clock.ts measures it. The
6
6
  * session that owns the task keeps it beside the task's state as work.json,
7
7
  * refreshed while it works, so every window can show the time ticking and
8
8
  * mark the steps being worked on. A file its owner stopped refreshing (the
@@ -31,7 +31,6 @@ export interface WorkTime {
31
31
  active: ActiveRun[];
32
32
  /** When the owning session last wrote it. */
33
33
  updatedAt: string;
34
- stepLimits?: Record<string, { limitMs: number; usedMs: number }>;
35
34
  }
36
35
 
37
36
  /** The clock as a reader sees it at one moment. */
@@ -51,16 +50,14 @@ export function workPath(root: string, configDir: string, taskId: string): strin
51
50
 
52
51
  function isWork(value: unknown): value is WorkTime {
53
52
  const work = value as Partial<WorkTime> | undefined;
54
- return Boolean(work && Number.isFinite(work.workedMs) && typeof work.updatedAt === "string"
55
- && (work.stepLimits === undefined || (work.stepLimits !== null && typeof work.stepLimits === "object" && !Array.isArray(work.stepLimits)
56
- && Object.values(work.stepLimits).every((entry) => entry && Number.isFinite(entry.limitMs) && Number.isFinite(entry.usedMs) && entry.limitMs >= 0 && entry.usedMs >= 0))));
53
+ return Boolean(work && Number.isFinite(work.workedMs) && typeof work.updatedAt === "string");
57
54
  }
58
55
 
59
56
  export function readWork(root: string, configDir: string, taskId: string, strict = false): WorkTime | undefined {
60
57
  try {
61
58
  const value: unknown = JSON.parse(readFileSync(workPath(root, configDir, taskId), "utf8"));
62
59
  if (!isWork(value)) throw new Error("Invalid task work record");
63
- return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt, ...(value.stepLimits ? { stepLimits: value.stepLimits } : {}) };
60
+ return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt };
64
61
  } catch (error) {
65
62
  if (strict && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
66
63
  return undefined;
@@ -80,7 +77,7 @@ export function updateWork(root: string, configDir: string, taskId: string, chan
80
77
  renameSync(tmp, path);
81
78
  } catch (error) {
82
79
  if (strict) throw error;
83
- // Display clocks may stop; never overwrite an unreadable allowance ledger.
80
+ // A display clock may stop; it never holds up the work.
84
81
  }
85
82
  }
86
83
 
@@ -21,7 +21,7 @@ import { plansDiscard, plansGet, plansStart } from "./plans.ts";
21
21
  import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
22
22
  import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
23
23
  import { sessionsAnswer, sessionsArchive, sessionsChat, sessionsList, sessionsMessage, sessionsRename, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
24
- import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
24
+ import { settingsGet, settingsGrants, settingsLinters, settingsSet } from "./settings.ts";
25
25
  import { terminalClose, terminalInput, terminalList, terminalOpen, terminalResize } from "./terminal.ts";
26
26
  import { MAX_INPUT_CHARS } from "../terminal.ts";
27
27
  import { themesChoose, themesGet, themesRemove, themesRename, themesSave } from "./themes.ts";
@@ -385,6 +385,7 @@ function buildRoutes(): Record<string, Route> {
385
385
  run: (body, ctx) => settingsSet(body as { patch: Record<string, unknown> }, ctx),
386
386
  };
387
387
  routes["settings.linters"] = { schema: Empty, run: (_body, ctx) => settingsLinters(ctx) };
388
+ routes["settings.grants"] = { schema: Empty, run: (_body, ctx) => settingsGrants(ctx) };
388
389
  const ThemeId = Type.String({ minLength: 1, maxLength: 40 });
389
390
  const ThemeName = Type.String({ minLength: 1, maxLength: 200 });
390
391
  const ThemeVars = Type.Optional(Type.Record(Type.String({ maxLength: 60 }), Type.String({ maxLength: 400 })));
@@ -10,7 +10,7 @@ import { lobbyTopics } from "../../lobby/topics.ts";
10
10
  import { prefillModels } from "../../pi/model-settings.ts";
11
11
  import { kindLabel } from "../../pi/model-support.ts";
12
12
  import { pushNotice } from "../notices.ts";
13
- import type { SettingsInfo, SettingsLinter } from "../protocol.ts";
13
+ import type { SettingsGrants, SettingsInfo, SettingsLinter } from "../protocol.ts";
14
14
  import type { ApiContext } from "./index.ts";
15
15
  import { fail } from "./index.ts";
16
16
 
@@ -61,22 +61,6 @@ function checkPort(patch: Record<string, unknown>): void {
61
61
  }
62
62
  }
63
63
 
64
- function agentEntries(patch: Record<string, unknown>): unknown[] {
65
- const agents = isPlain(patch.agents) ? Object.values(patch.agents) : [];
66
- return [patch.master, ...agents, patch.researcher, patch.quickFix, patch.planner, patch.scout];
67
- }
68
-
69
- /** Reject a time limit that is not a positive number, . */
70
- function checkTimeouts(patch: Record<string, unknown>): void {
71
- for (const entry of agentEntries(patch)) {
72
- const timeout = isPlain(entry) ? entry.timeoutMs : undefined;
73
- if (timeout === undefined) continue;
74
- if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
75
- fail(400, "bad_request", `"${String(timeout)}" is not a positive number of minutes.`);
76
- }
77
- }
78
- }
79
-
80
64
  /**
81
65
  * The page's first settings call. Subagents never inherit a model silently:
82
66
  * any whose model is still unset is pinned to the session's model and saved,
@@ -99,10 +83,14 @@ export function settingsLinters(ctx: ApiContext): { linters: SettingsLinter[] }
99
83
  return { linters: ctx.service.linters?.() ?? [] };
100
84
  }
101
85
 
86
+ /** The MCP tools and skills pi offers now, for each agent's grants. */
87
+ export function settingsGrants(ctx: ApiContext): SettingsGrants {
88
+ return ctx.service.grants?.() ?? { mcpTools: [], skills: [] };
89
+ }
90
+
102
91
  /** Merge, normalise and save the patch; the lobby rereads the config. */
103
92
  export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
104
93
  checkPort(body.patch);
105
- checkTimeouts(body.patch);
106
94
  const config = stripSecrets(resolveConfig(mergeConfig(readConfig(ctx), body.patch)));
107
95
  writeConfig(ctx, config);
108
96
  ctx.service.configChanged?.();
@@ -409,6 +409,20 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
409
409
  configChanged: () => {},
410
410
  models: () => models.map((model) => ({ id: model.id, label: model.label, thinkingLevels: [...model.thinkingLevels] })),
411
411
  classifierModels: () => [{ id: "opencode/jev-1.13-free", label: "Jev 1.13 (free)" }, { id: "openrouter/liquid/d1", label: "Liquid D1" }],
412
+ grants: () => ({
413
+ mcpTools: [
414
+ { name: "mcp__github__get_issue", server: "github", description: "Read one issue with its comments.", readOnly: true },
415
+ { name: "mcp__github__create_pull_request", server: "github", description: "Open a pull request.", readOnly: false },
416
+ { name: "mcp__github__search_code", server: "github", description: "Search code across repositories.", readOnly: true },
417
+ { name: "mcp__figma__get_frame", server: "figma", description: "Read a frame's layers and styles.", readOnly: true },
418
+ { name: "mcp__figma__export_image", server: "figma", description: "Export a frame as PNG or SVG.", readOnly: false },
419
+ ],
420
+ skills: [
421
+ { name: "pdf-tools", description: "Extract text and tables from PDF files.", path: "~/.pi/agent/skills/pdf-tools/SKILL.md" },
422
+ { name: "release-notes", description: "Write release notes from merged pull requests.", path: ".pi/skills/release-notes/SKILL.md" },
423
+ { name: "tailwind-review", description: "Check Tailwind classes against the design tokens.", path: ".agents/skills/tailwind-review/SKILL.md" },
424
+ ],
425
+ }),
412
426
  issuesEnabled: () => fixture.status.issuesEnabled,
413
427
  workspace: () => ({ ...fixture.status.workspace }),
414
428
  zen: () => ({ ...(fixture.zen.task ? { task: { ...fixture.zen.task } } : {}), runs: [...fixture.zen.runs] }),
@@ -210,6 +210,28 @@ export interface SettingsClassifierModel {
210
210
  label: string;
211
211
  }
212
212
 
213
+ /** An MCP tool Settings can grant (`settings.grants`). */
214
+ export interface SettingsMcpTool {
215
+ name: string;
216
+ server: string;
217
+ description: string;
218
+ /** The server marks it read-only: read-only agents (scouts, QA, researcher) may call it. */
219
+ readOnly: boolean;
220
+ }
221
+
222
+ /** A skill Settings can grant (`settings.grants`). */
223
+ export interface SettingsSkill {
224
+ name: string;
225
+ description: string;
226
+ path: string;
227
+ }
228
+
229
+ /** `settings.grants`: what pi offers to grant agents now. */
230
+ export interface SettingsGrants {
231
+ mcpTools: SettingsMcpTool[];
232
+ skills: SettingsSkill[];
233
+ }
234
+
213
235
  /** One message on the event stream (`GET /api/events`, server-sent events). */
214
236
  export type StreamEvent =
215
237
  | { type: "hello"; versions: Partial<Record<LobbyTopic, number>> }
@@ -606,6 +628,7 @@ export interface Api {
606
628
  "settings.get": { request: Record<string, never>; result: SettingsInfo };
607
629
  "settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
608
630
  "settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
631
+ "settings.grants": { request: Record<string, never>; result: SettingsGrants };
609
632
  "themes.get": { request: Record<string, never>; result: ThemeStoreInfo };
610
633
  "themes.save": { request: { name: string; light?: Record<string, string>; dark?: Record<string, string> }; result: ThemeStoreInfo & { id: string } };
611
634
  "themes.rename": { request: { id: string; name: string }; result: ThemeStoreInfo };