@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
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
- import { isAbsolute, join, relative } from "node:path";
3
- import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
5
5
  import {
6
6
  MAX_RUN_LOG,
@@ -24,6 +24,7 @@ import { ExcalidrawBook } from "../excalidraw/sessions.ts";
24
24
  import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
25
25
  import { lobbyTopics } from "../lobby/topics.ts";
26
26
  import { writeScratchpad } from "../state/persistence.ts";
27
+ import { saveScript } from "../scripts/library.ts";
27
28
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
28
29
  import { previewDir } from "../ask/relay.ts";
29
30
  import type { AskQuestion, AskResult } from "../ask/types.ts";
@@ -55,6 +56,8 @@ import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApp
55
56
  import { withBlockingRequest } from "../state/blocking-requests.ts";
56
57
  import { pingApproval } from "../pi/notify.ts";
57
58
  import { describeRun, runLogEntry } from "../pi/run-summary.ts";
59
+ import { planChecklist, planSteps, runSteps } from "../pi/plan-checklist.ts";
60
+ import { STEP_LIMIT_MS, grantStepTime, reserveStepTime, stepTimeLeft } from "../state/step-limits.ts";
58
61
  import { noteWorkerRun } from "../state/work-time.ts";
59
62
  import { nextStates } from "./transitions.ts";
60
63
  import type { FileHinter } from "../classifier/files.ts";
@@ -88,6 +91,7 @@ export const ORCHESTRATE_ACTIONS = [
88
91
  "qa",
89
92
  "knowledge",
90
93
  "compact",
94
+ "script",
91
95
  "whiteboard",
92
96
  "complete",
93
97
  "block",
@@ -124,10 +128,14 @@ export interface OrchestrateParams {
124
128
  minutes?: number;
125
129
  /** knowledge: which persistent file the text belongs to. */
126
130
  kind?: KnowledgeKind;
127
- /** compact: the knowledge file being rewritten. */
131
+ /** compact: the knowledge file being rewritten; script: a drafted script to take instead of `text`. */
128
132
  file?: string;
129
- /** whiteboard: name for the new Excalidraw session. */
133
+ /** whiteboard: name for the new Excalidraw session; script: the script's file name (`seed_public_holidays.py`). */
130
134
  name?: string;
135
+ /** script: one line on what the script does. */
136
+ purpose?: string;
137
+ /** script: how to run it, with its arguments. */
138
+ usage?: string;
131
139
  approvalId?: string;
132
140
  decision?: "approved" | "rejected";
133
141
  note?: string;
@@ -633,6 +641,9 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
633
641
  result.knowledgeProposals.length > 0
634
642
  ? `Knowledge proposals (accept with action=knowledge, or ignore): ${result.knowledgeProposals.map((proposal) => `${proposal.kind}: ${truncate(proposal.content, 160)}`).join("; ")}`
635
643
  : "",
644
+ result.scriptProposals?.length
645
+ ? `Script proposals (save with action=script name purpose usage file=<draft>, when the job will come back; or ignore): ${result.scriptProposals.map((proposal) => `${proposal.name}${proposal.purpose ? ` — ${truncate(proposal.purpose, 120)}` : ""}${proposal.draft ? ` (draft: ${proposal.draft})` : ""}`).join("; ")}`
646
+ : "",
636
647
  issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
637
648
  pushback && objection
638
649
  ? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
@@ -993,7 +1004,6 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
993
1004
  for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
994
1005
  for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
995
1006
  // Under a time budget every step is given its share before any starts; a spent budget starts none.
996
- const times = workerTimes(task, deps, assignments);
997
1007
  if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
998
1008
  if (starting) startFast(task);
999
1009
  if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
@@ -1001,6 +1011,7 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
1001
1011
  await takeBaseline(task, deps);
1002
1012
  // A QA step after work is in verifies it at its risk: read the change first.
1003
1013
  if (assignments.some((entry) => entry.domain === "qa") && builtSomething(task)) await assessBuilt(task, deps);
1014
+ const times = await workerTimes(task, deps, assignments);
1004
1015
  let report: string;
1005
1016
  try {
1006
1017
  if (assignments.length === 1) {
@@ -1010,7 +1021,10 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
1010
1021
  } else report = await runParallelWorkers(task, deps, assignments, times);
1011
1022
  } finally {
1012
1023
  // A step that never reported (the call failed) does not stay "running" in the budget.
1013
- for (const time of times.values()) settleAllotment(task, deps, time.id, "stopped");
1024
+ for (const time of times.values()) {
1025
+ settleAllotment(task, deps, time.id, "stopped");
1026
+ time.finish?.();
1027
+ }
1014
1028
  }
1015
1029
  const tree = await changeState(deps, task, task.baseline?.head);
1016
1030
  const lint = lintNote(await lintTask(task, deps, tree), deps.config.lint.mode, ownersOf(tree?.files));
@@ -1364,7 +1378,7 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
1364
1378
  return qaReport(header, result, decision, provenance, lintLine);
1365
1379
  }
1366
1380
  const time = qaTime(task, deps);
1367
- const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
1381
+ const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), effort: deps.effort, ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
1368
1382
  if (time) settleAllotment(task, deps, time.id, "finished");
1369
1383
  const { result, run } = outcome;
1370
1384
  recordAdvisoryPushbacks(task, [{ pushback: result.pushback, who: "qa" }]);
@@ -1476,6 +1490,34 @@ function handleKnowledge(task: Task | undefined, params: OrchestrateParams, deps
1476
1490
  return `Recorded ${kind} for ${agent} in ${outcome.path}.`;
1477
1491
  }
1478
1492
 
1493
+ /**
1494
+ * The oracle adds a script to the shared library, or replaces one, so every
1495
+ * agent can run it the next time the job comes up. Its body is `text`, or a
1496
+ * file a worker drafted (`file`, inside the project or the task's directory).
1497
+ */
1498
+ function handleScript(task: Task | undefined, params: OrchestrateParams, deps: WorkflowDeps): string {
1499
+ const name = params.name?.trim();
1500
+ if (!name) throw new Error("script requires name: what it does in snake_case, with its extension (seed_public_holidays.py)");
1501
+ const purpose = params.purpose?.trim();
1502
+ if (!purpose) throw new Error("script requires purpose: one line on what it does");
1503
+ const body = params.file?.trim() ? readDraft(params.file.trim(), task, deps) : params.text;
1504
+ if (!body?.trim()) throw new Error("script requires text (the script) or file (a drafted script)");
1505
+ const saved = saveScript({ dataRoot: dataRoot(deps.root, deps.configDir), name, purpose, body, ...(params.usage?.trim() ? { usage: params.usage.trim() } : {}) });
1506
+ const { entry } = saved;
1507
+ if (task) recordDecision(task, `${saved.archived ? "Replaced" : "Saved"} the shared script ${entry.name}: ${truncate(entry.purpose, 160)}`);
1508
+ lobbyTopics.bump("knowledge");
1509
+ return [`${saved.archived ? "Replaced" : "Saved"} ${entry.name} in the shared scripts library (${entry.path}); run: ${entry.usage}.`, saved.archived ? `The previous version is kept at ${saved.archived}.` : "", "Agents whose steps it fits are told to run it."].filter(Boolean).join(" ");
1510
+ }
1511
+
1512
+ /** A drafted script, read only from inside the project, its worktree or the task's directory. */
1513
+ function readDraft(file: string, task: Task | undefined, deps: WorkflowDeps): string {
1514
+ const roots = [deps.cwd, deps.root, ...(task ? [taskDirFor(deps.root, deps.configDir, task.id)] : [])].map((dir) => resolve(dir));
1515
+ const path = isAbsolute(file) ? resolve(file) : resolve(deps.cwd, file);
1516
+ if (!roots.some((root) => path === root || path.startsWith(`${root}${sep}`))) throw new Error(`the drafted script must be inside the project: ${file}`);
1517
+ if (!existsSync(path)) throw new Error(`no drafted script at ${file}`);
1518
+ return readFileSync(path, "utf8");
1519
+ }
1520
+
1479
1521
  /**
1480
1522
  * §24: the Master rewrites the file (asking the user about ambiguity), the
1481
1523
  * engine archives the previous version and writes the compacted text.
@@ -1705,7 +1747,7 @@ const RESEARCH_SHARE = 0.15;
1705
1747
  const DEFAULT_MORE_MINUTES = 10;
1706
1748
 
1707
1749
  /** A delegation's time, with the allotment it is recorded under. */
1708
- type WorkerTime = AgentTime & { id: string };
1750
+ type WorkerTime = AgentTime & { id: string; finish?: () => void };
1709
1751
 
1710
1752
  /** The task's budget and where it stands, when it has one. */
1711
1753
  function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
@@ -1726,7 +1768,7 @@ function timeNote(allotMs: number, state: BudgetState, worker: boolean): string
1726
1768
  "## Time",
1727
1769
  `You have ${formatMinutes(allotMs)} for this ${worker ? "step" : "work"}; the task has ${formatMinutes(state.leftMs)} of its ${formatMinutes(state.totalMs)} left.`,
1728
1770
  worker
1729
- ? "At about 75% you get a heads-up. When the time is up you are asked to stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); the user decides whether you get it. Land the most important part first, and keep every file consistent as you go."
1771
+ ? "This is the total allowance shared across startup, retries, children, tools and reporting. At about 75% you get a heads-up. Before the hard deadline, stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); only the user can grant it. Land the most important part first, and keep every file consistent as you go."
1730
1772
  : "When the time is up you are asked to stop and report what you have, so cover the most important questions first.",
1731
1773
  ].join("\n");
1732
1774
  }
@@ -1752,7 +1794,7 @@ function oneLine(text: string, max: number): string {
1752
1794
  function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, share: number, limitMs: number, label: string): WorkerTime | undefined {
1753
1795
  const now = budgetFor(task, deps);
1754
1796
  if (!now) return undefined;
1755
- const ms = readerAllotment(now.state, share, limitMs);
1797
+ const ms = readerAllotment(now.state, share, Math.min(STEP_LIMIT_MS, limitMs));
1756
1798
  if (ms === undefined) throw budgetSpent(now.state, label);
1757
1799
  const id = `${who.toLowerCase()}-${Date.now().toString(36)}`;
1758
1800
  recordAllotment(task, deps, { id, who, what: oneLine(what, 120), minutes: Math.round(ms / 60_000) });
@@ -1763,7 +1805,7 @@ function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, s
1763
1805
  function qaTime(task: Task, deps: WorkflowDeps): WorkerTime | undefined {
1764
1806
  const now = budgetFor(task, deps);
1765
1807
  if (!now) return undefined;
1766
- const ms = qaAllotment(now.state, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs);
1808
+ const ms = qaAllotment(now.state, Math.min(STEP_LIMIT_MS, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs));
1767
1809
  if (ms === undefined) throw budgetSpent(now.state, "the QA gate");
1768
1810
  const id = `qa-${Date.now().toString(36)}`;
1769
1811
  recordAllotment(task, deps, { id, who: "QA gate", what: "review the task's work", minutes: Math.round(ms / 60_000) });
@@ -1781,18 +1823,58 @@ function openSlots(task: Task, batch: readonly Domain[]): number {
1781
1823
  * for (it divides by scope) or an even share, never past what is left before
1782
1824
  * the QA gate's reserve. A spent budget starts nothing.
1783
1825
  */
1784
- function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Map<Domain, WorkerTime> {
1826
+ async function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Promise<Map<Domain, WorkerTime>> {
1785
1827
  const times = new Map<Domain, WorkerTime>();
1786
1828
  const now = budgetFor(task, deps);
1787
- if (!now) return times;
1788
1829
  const slots = openSlots(task, assignments.map((entry) => entry.domain));
1830
+ const steps = planSteps(task.plan ?? "");
1831
+ const completed = new Set(planChecklist(task.plan ?? "", (task.workerRuns ?? []).map((record) => ({ ...record, taskId: task.id, role: "worker" as const, output: "", attempts: 1 }))).flatMap((step, index) => step.status === "done" ? [index] : []));
1832
+ const prepared = [];
1789
1833
  for (const { domain, instruction, minutes } of assignments) {
1790
- const given = workerAllotment(now.state, minutes, slots);
1791
- if (!given) throw budgetSpent(now.state, `the ${domain} step`);
1792
- const id = `${domain}-${Date.now().toString(36)}`;
1793
- recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(given.ms / 60_000) });
1794
- if (given.note) recordDecision(task, `${AGENT_LABELS[domain]}'s step: ${given.note}.`);
1795
- times.set(domain, { id, endsAt: Date.now() + given.ms, allotMs: given.ms, note: timeNote(given.ms, now.state, true), onTimeUp: moreTimeFor(task, deps, domain, instruction, id) });
1834
+ const covered = runSteps(steps, instruction, completed);
1835
+ const keys = covered.length ? covered.map((index) => `${domain}:step-${index + 1}`) : [`${domain}:unplanned`];
1836
+ const limitMs = Math.min(STEP_LIMIT_MS, profileFor(deps.config, deps.profile, domain, "worker").timeoutMs);
1837
+ let left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
1838
+ if (left <= 0) {
1839
+ const wanted = DEFAULT_MORE_MINUTES;
1840
+ const granted = await decideMoreTime(task, deps, {
1841
+ label: AGENT_LABELS[domain], instruction, wanted,
1842
+ result: parseWorkerResult(domain, scratchpadSummary(deps, task, domain)),
1843
+ run: { runId: "", taskId: task.id, domain, role: "worker", status: "timeout", output: "", attempts: 1, startedAt: new Date().toISOString(), allotMs: limitMs },
1844
+ state: now?.state,
1845
+ });
1846
+ if (granted <= 0) throw new Error(`${AGENT_LABELS[domain]}'s step allowance is exhausted. Return the unfinished report; continuing requires user-approved time.`);
1847
+ grantStepTime(deps.root, deps.configDir, task.id, keys, limitMs, granted * 60_000);
1848
+ recordDecision(task, `The user gave ${AGENT_LABELS[domain]}'s exhausted step ${granted} more minutes.`);
1849
+ left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
1850
+ }
1851
+ const given = now ? workerAllotment(now.state, minutes, slots) : { ms: left };
1852
+ if (!given) throw budgetSpent(now!.state, `the ${domain} step`);
1853
+ const ms = Math.min(left, given.ms);
1854
+ prepared.push({ domain, instruction, keys, limitMs, ms });
1855
+ }
1856
+ try {
1857
+ for (const { domain, instruction, keys, limitMs, ms } of prepared) {
1858
+ const reservation = reserveStepTime(deps.root, deps.configDir, task.id, keys, limitMs, ms);
1859
+ const id = `${domain}-${Date.now().toString(36)}`;
1860
+ let exhausted = false;
1861
+ const askMore = moreTimeFor(task, deps, domain, instruction, id, (extraMs) => reservation.grant(extraMs));
1862
+ recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(ms / 60_000) });
1863
+ const time: WorkerTime = { id, endsAt: Date.now() + ms, allotMs: ms, strict: true,
1864
+ note: now ? timeNote(ms, now.state, true) : `## Time\nYou have ${formatMinutes(ms)} total for this step, shared across retries and children. Report unfinished work before the deadline; only the user can grant more time.`,
1865
+ onTimeUp: async (run, report) => {
1866
+ const extraMs = await askMore(run, report);
1867
+ const result = parseWorkerResult(domain, report);
1868
+ if (extraMs <= 0 && (result.leftOff || result.moreTime)) exhausted = true;
1869
+ return extraMs;
1870
+ },
1871
+ };
1872
+ time.finish = () => reservation.finish(time.usedMs ?? ms, exhausted);
1873
+ times.set(domain, time);
1874
+ }
1875
+ } catch (error) {
1876
+ for (const time of times.values()) { time.usedMs = 0; time.finish?.(); }
1877
+ throw error;
1796
1878
  }
1797
1879
  return times;
1798
1880
  }
@@ -1809,30 +1891,27 @@ function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
1809
1891
 
1810
1892
  /**
1811
1893
  * A worker out of time has reported what it did, where it left off and how
1812
- * much more it needs. The user decides (auto mode: once, and only from what
1813
- * is left before the QA gate's reserve); a grant past that grows the budget,
1894
+ * much more it needs. Only the user grants more time; a grant past the task budget grows it,
1814
1895
  * and the same agent carries on. Resolves with the ms granted, 0 to stop it.
1815
1896
  */
1816
- function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string): NonNullable<AgentTime["onTimeUp"]> {
1817
- let asked = 0;
1897
+ function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string, onGrant?: (ms: number) => void): NonNullable<AgentTime["onTimeUp"]> {
1818
1898
  return (run, report) => oneAtATime(async () => {
1819
1899
  const result = parseWorkerResult(domain, report);
1820
1900
  // A report with nothing left to do finished in time.
1821
1901
  if (!result.leftOff && !result.moreTime) return 0;
1822
- asked += 1;
1823
1902
  const label = AGENT_LABELS[domain];
1824
1903
  const wanted = result.moreTime?.minutes ?? DEFAULT_MORE_MINUTES;
1825
1904
  const now = budgetFor(task, deps);
1826
- if (!now) return 0;
1827
- const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, asked, state: now.state });
1905
+ const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, state: now?.state });
1828
1906
  if (minutes <= 0) return 0;
1829
- const over = Math.max(0, Math.ceil((minutes * 60_000 - now.state.windowMs) / 60_000));
1907
+ const over = Math.max(0, Math.ceil((minutes * 60_000 - (now?.state.windowMs ?? minutes * 60_000)) / 60_000));
1830
1908
  updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1831
1909
  budget.granted += over;
1832
1910
  const entry = budget.allotments.find((allotment) => allotment.id === id);
1833
1911
  if (entry) Object.assign(entry, { minutes: entry.minutes + minutes, granted: entry.granted + minutes });
1834
1912
  });
1835
- recordDecision(task, `${label} ran out of time; ${isAutoMode(deps.root, deps.configDir, task.id) ? "auto mode gave it" : "the user gave it"} ${minutes} more minutes${over > 0 ? `, ${over} of them added to the task's budget` : ""}. Left off: ${oneLine(result.leftOff ?? result.moreTime?.reason ?? "", 200)}`);
1913
+ recordDecision(task, `${label} ran out of time; the user gave it ${minutes} more minutes${over > 0 ? `, ${over} of them added to the task's budget` : ""}. Left off: ${oneLine(result.leftOff ?? result.moreTime?.reason ?? "", 200)}`);
1914
+ onGrant?.(minutes * 60_000);
1836
1915
  return minutes * 60_000;
1837
1916
  });
1838
1917
  }
@@ -1843,22 +1922,19 @@ interface MoreTimeAsk {
1843
1922
  result: WorkerResult;
1844
1923
  wanted: number;
1845
1924
  run: AgentRun;
1846
- asked: number;
1847
- state: BudgetState;
1925
+ state?: BudgetState;
1848
1926
  }
1849
1927
 
1850
1928
  const MORE_TIME = "Give it the time it asks for";
1851
1929
  const OTHER_TIME = "Give a different amount";
1852
1930
 
1853
1931
  async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk): Promise<number> {
1854
- const { label, instruction, result, wanted, run, asked, state } = ask;
1932
+ const { label, instruction, result, wanted, run, state } = ask;
1855
1933
  if (isAutoMode(deps.root, deps.configDir, task.id)) {
1856
- // Nobody to ask: once, and only from time the task still has before the QA gate's reserve.
1857
- if (asked === 1 && wanted * 60_000 <= state.windowMs) return wanted;
1858
- recordDecision(task, `Auto mode: ${label} ran out of time and was not given more (${asked > 1 ? "it already had more once" : `it asked for ${wanted} minutes, ${formatMinutes(state.windowMs)} were left`}).`);
1934
+ recordDecision(task, `Auto mode: ${label}'s step allowance is spent. More time requires the user; return the unfinished report.`);
1859
1935
  return 0;
1860
1936
  }
1861
- const over = Math.max(0, Math.ceil((wanted * 60_000 - state.windowMs) / 60_000));
1937
+ const over = Math.max(0, Math.ceil((wanted * 60_000 - (state?.windowMs ?? wanted * 60_000)) / 60_000));
1862
1938
  const had = run.allotMs ?? 0;
1863
1939
  const choice = await deps.choose(
1864
1940
  [
@@ -1866,7 +1942,7 @@ async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk):
1866
1942
  result.completed ? `Done so far: ${oneLine(result.completed, 400)}` : "",
1867
1943
  result.leftOff ? `Left to do: ${oneLine(result.leftOff, 400)}` : "",
1868
1944
  `It needs about ${wanted} more minutes${result.moreTime?.reason ? `: ${oneLine(result.moreTime.reason, 200)}` : ""}.`,
1869
- `The task has used ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} (${formatMinutes(state.leftMs)} left)${over > 0 ? `; ${wanted} more minutes adds ${over} to its budget` : ""}.`,
1945
+ state ? `The task has used ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} (${formatMinutes(state.leftMs)} left)${over > 0 ? `; ${wanted} more minutes adds ${over} to its budget` : ""}.` : "This step has a shared allowance even though the task has no overall time budget.",
1870
1946
  `Give ${label} ${wanted} more minutes to finish?`,
1871
1947
  ].filter(Boolean).join("\n\n"),
1872
1948
  [MORE_TIME, OTHER_TIME, `Stop ${label} here`],
@@ -1879,7 +1955,7 @@ async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk):
1879
1955
 
1880
1956
  /** Stopped at its time with work left: a report that finished everything as time ran out is not. */
1881
1957
  function stoppedForTime(outcome: WorkerOutcome): boolean {
1882
- return Boolean(outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime));
1958
+ return Boolean(outcome.run.status === "timeout" || (outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime)));
1883
1959
  }
1884
1960
 
1885
1961
  /** How a step's time went, for the oracle: out of time and stopped, or given more. */
@@ -1951,6 +2027,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1951
2027
  resolve_approval: handleResolveApproval,
1952
2028
  qa: handleQa,
1953
2029
  knowledge: handleKnowledge,
2030
+ script: handleScript,
1954
2031
  compact: handleCompact,
1955
2032
  whiteboard: handleWhiteboard,
1956
2033
  complete: handleComplete,
@@ -1969,9 +2046,9 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1969
2046
  */
1970
2047
  export async function runWorkflowAction(params: OrchestrateParams, deps: WorkflowDeps): Promise<WorkflowResult> {
1971
2048
  // Project knowledge can be learned without starting or changing a task.
1972
- if ((params.action === "knowledge" || params.action === "compact") && !params.taskId) {
2049
+ if ((params.action === "knowledge" || params.action === "compact" || params.action === "script") && !params.taskId) {
1973
2050
  try {
1974
- const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : handleCompact(undefined, params, deps);
2051
+ const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : params.action === "script" ? handleScript(undefined, params, deps) : handleCompact(undefined, params, deps);
1975
2052
  return { ok: true, taskId: "", state: "created", message };
1976
2053
  } catch (error) {
1977
2054
  return { ok: false, taskId: "", state: "created", message: `Rejected: ${(error as Error).message}` };
@@ -2011,6 +2088,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
2011
2088
  ...(deps.askQuestions ? { askQuestions: (...args: Parameters<NonNullable<WorkflowDeps["askQuestions"]>>) => wait(() => deps.askQuestions!(...args)) } : {}),
2012
2089
  // Every agent, diff and desk of a task with its own worktree works there.
2013
2090
  cwd: taskCwd(task, deps.cwd),
2091
+ // An agent can work for an hour: what the action has done so far (implementing, reviewing) is saved before it starts, so every window reads it.
2092
+ runProcess: (args, options) => {
2093
+ saveTask(deps.root, deps.configDir, task);
2094
+ return (deps.runProcess ?? spawnPiProcess)(args, options);
2095
+ },
2014
2096
  onUpdate: (run) => {
2015
2097
  if (run.status !== "running") {
2016
2098
  finished.set(run.runId, run);