@a-t-h-i/bot-lobby 0.6.25 → 0.6.26

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 +3 -1
  2. package/package.json +1 -5
  3. package/prompts/master.md +12 -7
  4. package/prompts/planner.md +5 -2
  5. package/src/classifier/hosts.ts +0 -11
  6. package/src/classifier/instance.ts +0 -5
  7. package/src/execution/agent-runner.ts +0 -18
  8. package/src/knowledge/compactor.ts +0 -70
  9. package/src/lobby/ask.ts +0 -15
  10. package/src/lobby/host.ts +2 -0
  11. package/src/lobby/issues.ts +0 -8
  12. package/src/lobby/pulls.ts +1 -44
  13. package/src/lobby/runtime.ts +1 -1
  14. package/src/lobby/service.ts +3 -1
  15. package/src/lobby/session-files.ts +1 -45
  16. package/src/lobby/split.ts +33 -1
  17. package/src/lobby/task-rows.ts +2 -0
  18. package/src/master/research.ts +0 -16
  19. package/src/pi/events.ts +9 -3
  20. package/src/pi/model-settings.ts +0 -1
  21. package/src/pi/plan-checklist.ts +98 -8
  22. package/src/pi/route.ts +0 -1
  23. package/src/pi/run-summary.ts +0 -57
  24. package/src/schemas/agent.ts +0 -4
  25. package/src/schemas/configuration.ts +0 -2
  26. package/src/state/budget.ts +49 -22
  27. package/src/state/metrics.ts +0 -1
  28. package/src/state/persistence.ts +0 -5
  29. package/src/state/work-time.ts +123 -0
  30. package/src/webui/api/index.ts +0 -5
  31. package/src/webui/api/lobby.ts +13 -6
  32. package/src/webui/api/plan-facts.ts +36 -6
  33. package/src/webui/api/tasks.ts +27 -6
  34. package/src/webui/dev/fake-service.ts +6 -0
  35. package/src/webui/dev/fixtures.ts +2 -0
  36. package/src/webui/protocol.ts +20 -3
  37. package/src/workflow/workflow.ts +35 -8
  38. package/webui/dist/assets/index-C1IwecNS.css +2 -0
  39. package/webui/dist/assets/{index-BRVbs5In.js → index-C4Ww36TW.js} +38 -38
  40. package/webui/dist/build.json +25 -37
  41. package/webui/dist/index.html +2 -2
  42. package/webui/dist/assets/index-BeRXVi_r.css +0 -2
package/README.md CHANGED
@@ -20,7 +20,9 @@ The questionnaire and web tools are built in, so don't also install extensions w
20
20
  1. In Pi, run `/bot-lobby add a login page`. Bot-lobby turns on and the lobby opens in your browser.
21
21
  A new pi session starts with bot-lobby off, as plain pi: `Ctrl+Shift+M` or `/bot-lobby on|off` switches it.
22
22
  2. Answer the oracle's questions and approve its plan, or agree one with the panel in **Plan** first.
23
- 3. Follow the agents in **Lobby**. Models, effort and time limits are in **Settings**.
23
+ A plan opens with a short list of steps; the detail of each follows for whoever wants it.
24
+ 3. Follow the agents in **Lobby**: the step being worked on reads `active · 1m 12s`, and each task shows
25
+ how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
24
26
 
25
27
  Small changes go to **Quick fix**. `--task` forces a full task, `--fast` / `--full` picks the workflow,
26
28
  and `--branch` / `--worktree` isolates it in git. A finished isolated task pushes its branch and tries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -58,7 +58,6 @@
58
58
  "@earendil-works/pi-tui": "0.87.0",
59
59
  "@fontsource-variable/inter": "5.3.0",
60
60
  "@playwright/test": "1.62.0",
61
- "@shadcn/react": "0.3.1",
62
61
  "@tailwindcss/vite": "4.3.3",
63
62
  "@types/node": "^22.10.0",
64
63
  "@types/react": "19.3.0",
@@ -72,11 +71,8 @@
72
71
  "react": "19.3.0",
73
72
  "react-dom": "19.3.0",
74
73
  "react-markdown": "10.1.0",
75
- "recharts": "3.10.1",
76
74
  "remark-gfm": "4.0.1",
77
- "shiki": "4.5.0",
78
75
  "socket.io": "4.8.4",
79
- "sonner": "2.0.8",
80
76
  "tailwind-merge": "3.7.0",
81
77
  "tailwindcss": "4.3.3",
82
78
  "tw-animate-css": "1.4.0",
package/prompts/master.md CHANGED
@@ -169,10 +169,15 @@ integrations. One file has one owner: never split a file between domains, and
169
169
  never give DESIGN only the styling of something another domain built —
170
170
  whoever builds a visual thing owns how it looks.
171
171
 
172
- Write the plan's steps as a numbered list under a `## Steps` heading, and open
173
- each `implement` task with its step number (`Step 3: ...`, or `Steps 3-4: ...`
174
- when one delegation covers several) so the user's checklist tracks progress
175
- exactly.
172
+ Write the plan for the user to skim first and read in full only if they want
173
+ to. It opens with `## Steps`: a numbered list, one short plain line per step,
174
+ in the order they happen — what gets done, in a few words (`Add the work timer
175
+ to the task`), never how: no file paths, code, flags or jargon. That list is
176
+ the user's checklist, so keep it to the steps themselves. The detail follows
177
+ under `## Details`, one `### Step N: <the same words>` section per step, and
178
+ then the rest of the plan (contracts, risks, notes). Open each `implement` task
179
+ with its step number (`Step 3: ...`, or `Steps 3-4: ...` when one delegation
180
+ covers several) so the checklist tracks progress exactly.
176
181
 
177
182
  ## Briefing the agents
178
183
 
@@ -228,9 +233,9 @@ Rules for the brief:
228
233
  a few actions. Split anything larger into consecutive steps in the same
229
234
  `implement` call rather than leaving the agent to sequence it. Prefer more
230
235
  explicit detail to fewer, larger chunks.
231
- - The plan's steps are written to the same standard: each step names its
232
- files, its actions and its done criteria, so the brief is the step made
233
- explicit, never a new decision.
236
+ - Each step's detail section is written to the same standard: it names the
237
+ step's files, its actions and its done criteria, so the brief is the step
238
+ made explicit, never a new decision.
234
239
  - Scout and research instructions ask specific questions with the answer
235
240
  format you want (paths, names, versions, yes/no plus evidence), and say what
236
241
  you will do with the answer.
@@ -106,6 +106,11 @@ or when you decided everything yourself.)
106
106
  ## Plan
107
107
  The current draft, in Markdown:
108
108
 
109
+ ### Steps
110
+ 1. …
111
+ (first, so the user can skim the plan: one short plain line per step, in the
112
+ order they happen — what gets done in a few words, never how: no file paths,
113
+ code or jargon; the sections below carry the detail)
109
114
  ### Objective
110
115
  ### Scope and non-goals
111
116
  ### Acceptance criteria
@@ -115,6 +120,4 @@ The current draft, in Markdown:
115
120
  (what the user decided for DEV, DESIGN, QA and RESEARCH, one bullet each)
116
121
  ### Assumptions
117
122
  (what you decided without asking, each with its seat: "[QA] Test in evergreen browsers only")
118
- ### Steps
119
- 1. …
120
123
  ### Risks and open points
@@ -40,11 +40,6 @@ export const JEV_HOST_TABLE: Readonly<Record<JevHostId, JevHost>> = Object.freez
40
40
  /** The hosts `auto` tries, in order: OpenCode's free Jev first. */
41
41
  export const AUTO_ORDER: readonly JevHostId[] = ["opencode", "typesafe"];
42
42
 
43
- /** How a host name reads in settings. */
44
- export function hostLabel(name: JevHostName): string {
45
- return name === "auto" ? "Auto (OpenCode's free Jev, else TypeSafe)" : JEV_HOST_TABLE[name].label;
46
- }
47
-
48
43
  /** Resolves a pi provider's API key (stored credential, then its environment variable). */
49
44
  export type KeySource = (piProvider: string) => Promise<string | undefined>;
50
45
 
@@ -122,12 +117,6 @@ export function keyHint(config: Pick<ClassifierConfig, "provider">): string {
122
117
  return how(JEV_HOST_TABLE[config.provider]);
123
118
  }
124
119
 
125
- /** `ts_ab…cd`: enough to recognise which key is loaded, never the key. */
126
- export function maskKey(key: string): string {
127
- if (key.length <= 8) return "*".repeat(key.length);
128
- return `${key.slice(0, 4)}…${key.slice(-4)}`;
129
- }
130
-
131
120
  /** One line on where a host's key comes from, for settings and `/bot-lobby config`. */
132
121
  export function describeKey(host: JevHost, status: StatusSource | undefined, env: NodeJS.ProcessEnv = process.env): string {
133
122
  const found = configuredProvider(host, status);
@@ -76,11 +76,6 @@ export function keyStatus(piProvider: string): KeyStatus | undefined {
76
76
  return binding?.status(piProvider);
77
77
  }
78
78
 
79
- /** Tests: use this classifier for the process (undefined restores the default). */
80
- export function useClassifier(next: Classifier | undefined): void {
81
- instance = next;
82
- }
83
-
84
79
  /** Register the `typesafe` login entry and bind the classifier to each session's key store. */
85
80
  export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
86
81
  registerJevProvider(pi);
@@ -380,21 +380,3 @@ export async function mapConcurrent<TIn, TOut>(items: TIn[], limit: number, fn:
380
380
  export async function runParallel(requests: AgentRequest[], limit: number, run: ProcessRunner = spawnPiProcess): Promise<AgentRun[]> {
381
381
  return mapWithConcurrencyLimit(requests, limit, (request) => runAgent(request, run));
382
382
  }
383
-
384
- function lastOutput(results: AgentRun[]): string {
385
- return results.length > 0 ? results[results.length - 1]!.output : "";
386
- }
387
-
388
- /** Run agents in order, substituting `{previous}` with the prior output. */
389
- export async function runSequential(requests: AgentRequest[], run: ProcessRunner = spawnPiProcess): Promise<AgentRun[]> {
390
- const results: AgentRun[] = [];
391
- for (const request of requests) {
392
- const instruction = request.instruction.includes("{previous}")
393
- ? request.instruction.replaceAll("{previous}", lastOutput(results))
394
- : request.instruction;
395
- const result = await runAgent({ ...request, instruction }, run);
396
- results.push(result);
397
- if (result.status !== "success") break;
398
- }
399
- return results;
400
- }
@@ -4,76 +4,6 @@ import type { KnowledgeAgent } from "./paths.ts";
4
4
  import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir } from "./paths.ts";
5
5
  import { readFirstExisting, readFileOr, writeFileEnsured } from "./store.ts";
6
6
 
7
- export interface KnowledgeAnalysis {
8
- /** Lines that say the same thing twice. */
9
- duplicates: string[];
10
- /** Lines with hedging or unresolved wording. */
11
- ambiguity: string[];
12
- /** Different statements sharing a subject: candidates for conflict review. */
13
- conflictCandidates: { subject: string; statements: string[] }[];
14
- }
15
-
16
- const AMBIGUITY = /\bmaybe|possibly|probably|tbd|todo|unclear|not sure|some kind|etc\.|approximately\b/i;
17
- const HEDGE_STOPWORDS = new Set(["this", "that", "with", "from", "into", "when", "they", "have", "will", "should", "must"]);
18
-
19
- function significantLines(content: string, minLength = 12): string[] {
20
- return content
21
- .split("\n")
22
- .map((line) => line.replace(/^[-*#>\s]+/, "").trim())
23
- .filter((line) => line.length >= minLength);
24
- }
25
-
26
- function normalize(line: string): string {
27
- return line
28
- .toLowerCase()
29
- .replace(/[^\w\s]/g, "")
30
- .replace(/\s+/g, " ")
31
- .trim();
32
- }
33
-
34
- function subjectKey(line: string): string {
35
- return normalize(line)
36
- .split(" ")
37
- .filter((word) => word.length > 3 && !HEDGE_STOPWORDS.has(word))
38
- .slice(0, 2)
39
- .join(" ");
40
- }
41
-
42
- function findDuplicates(lines: string[]): string[] {
43
- const seen = new Map<string, string>();
44
- const duplicates: string[] = [];
45
- for (const line of lines) {
46
- const key = normalize(line);
47
- if (seen.has(key)) duplicates.push(line);
48
- else seen.set(key, line);
49
- }
50
- return duplicates;
51
- }
52
-
53
- function findConflicts(lines: string[]): { subject: string; statements: string[] }[] {
54
- const groups = new Map<string, Set<string>>();
55
- for (const line of lines) {
56
- const key = subjectKey(line);
57
- if (key.split(" ").length < 2) continue;
58
- const statements = groups.get(key) ?? new Set<string>();
59
- statements.add(line);
60
- groups.set(key, statements);
61
- }
62
- return [...groups.entries()]
63
- .filter(([, statements]) => statements.size > 1)
64
- .map(([subject, statements]) => ({ subject, statements: [...statements] }));
65
- }
66
-
67
- /** Mechanical candidates that help the Master decide what to compact (§24). */
68
- export function analyzeKnowledge(content: string): KnowledgeAnalysis {
69
- const lines = significantLines(content);
70
- return {
71
- duplicates: findDuplicates(lines),
72
- ambiguity: lines.filter((line) => AMBIGUITY.test(line)),
73
- conflictCandidates: findConflicts(lines),
74
- };
75
- }
76
-
77
7
  export interface OversizedFile {
78
8
  agent: KnowledgeAgent;
79
9
  file: string;
package/src/lobby/ask.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  * The panel's questions become questionnaires of at most four, and the
7
7
  * answers become the user's turn for the next round.
8
8
  */
9
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
9
  import type { PanelQuestion, SettledQuestion } from "./planner.ts";
11
10
  import { MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type Asker } from "../ask/types.ts";
12
11
 
@@ -122,17 +121,3 @@ export function answerMessage(questions: readonly PanelQuestion[], results: read
122
121
  ...(skipped.length > 0 ? ["", "Not answered this round:", ...skipped] : []),
123
122
  ].join("\n");
124
123
  }
125
-
126
- /**
127
- * Put every question to the user, one questionnaire after another. Stopping
128
- * a questionnaire (esc) stops the rest; what was answered before it is kept.
129
- */
130
- export async function askPanel(questions: readonly PanelQuestion[], ask: Asker, ctx: ExtensionContext): Promise<{ results: AskResult[]; stopped: boolean }> {
131
- const results: AskResult[] = [];
132
- for (const chunk of questionnaires(questions)) {
133
- const result = await ask(chunk, ctx);
134
- if (result.cancelled) return { results, stopped: true };
135
- results.push(result);
136
- }
137
- return { results, stopped: false };
138
- }
package/src/lobby/host.ts CHANGED
@@ -95,6 +95,8 @@ export interface LobbyService {
95
95
  feed: LobbyFeed;
96
96
  masterBusy(): boolean;
97
97
  tasks(): Task[];
98
+ /** How long a task's agents have worked on it, and the worker runs under way (`work.json`); undefined without one. */
99
+ work?(taskId: string): import("../state/work-time.ts").WorkProjection | undefined;
98
100
  deliveryDeliver?(taskId: string, request: import("../delivery/operations.ts").DeliveryRequest): Promise<import("../delivery/types.ts").Delivery>;
99
101
  deliveryReview?(taskId: string): Promise<import("../delivery/types.ts").Delivery>;
100
102
  deliveryDefer?(taskId: string, reviewId: string): import("../delivery/types.ts").Delivery;
@@ -154,14 +154,6 @@ export async function createIssue(exec: Exec, cwd: string, title: string, body:
154
154
  return { url, ...(Number.isFinite(number) ? { number } : {}) };
155
155
  }
156
156
 
157
- /** The issue as text for the planner: body plus comments. */
158
- export function issueText(issue: IssueDetail): string {
159
- const comments = issue.comments
160
- .filter((comment) => comment.body.trim())
161
- .map((comment) => `**${comment.author ?? "someone"}** commented:\n${comment.body.trim()}`);
162
- return [issue.body.trim() || "(no description)", ...comments].join("\n\n");
163
- }
164
-
165
157
  /** Lobby state for the Issues tab: the list, the open issue, and what is loading. */
166
158
  export class IssuesState {
167
159
  issues: IssueSummary[] = [];
@@ -4,7 +4,7 @@
4
4
  * Like issues, `gh` owns authentication and repository detection; every
5
5
  * failure becomes one readable line.
6
6
  */
7
- import { execCommand, gh, type Exec } from "./issues.ts";
7
+ import { gh, type Exec } from "./issues.ts";
8
8
 
9
9
  export type ChecksState = "passing" | "failing" | "pending";
10
10
 
@@ -193,49 +193,6 @@ export async function pullDiff(exec: Exec, cwd: string, number: number, limit =
193
193
  return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
194
194
  }
195
195
 
196
- /** A pull request opened for a completed task's branch. */
197
- export interface CreatedPull {
198
- number: number;
199
- url: string;
200
- }
201
-
202
- const PUSH_TIMEOUT_MS = 60_000;
203
-
204
- async function run(exec: Exec, cwd: string, command: string, args: string[]): Promise<string> {
205
- let result: Awaited<ReturnType<Exec>>;
206
- try {
207
- result = await exec(command, args, { cwd, timeout: PUSH_TIMEOUT_MS });
208
- } catch (error) {
209
- throw new Error(`${command} could not run — ${(error as Error).message}`);
210
- }
211
- if (result.code !== 0) throw new Error(result.stderr.trim() || `${command} exited with code ${result.code}`);
212
- return result.stdout;
213
- }
214
-
215
- /** `gh pr create` ends with the URL on its own last line; the number is its tail. */
216
- function parseCreated(stdout: string): CreatedPull {
217
- const url = stdout.trim().split("\n").map((line) => line.trim()).filter((line) => /^https?:\/\/\S+$/.test(line)).pop();
218
- if (!url) throw new Error("gh pr create printed no pull request URL");
219
- const match = /\/pull\/([1-9]\d*)$/.exec(url);
220
- const number = Number(match?.[1]);
221
- if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`gh pr create printed an unreadable URL: ${url}`);
222
- return { number, url };
223
- }
224
-
225
- /**
226
- * Push a finished task's branch and open its pull request, so the work lands
227
- * in review without the oracle having to remember a second step.
228
- */
229
- export async function createPullRequest(options: { exec?: Exec; cwd: string; branch: string; base: string; title: string; body: string }): Promise<CreatedPull> {
230
- for (const ref of [options.branch, options.base]) {
231
- if (!ref || ref.startsWith("-") || /[\s~^:?*\[\\]|\.\.|@\{|\/\//.test(ref) || /[/.]$/.test(ref)) throw new Error("invalid pull request branch");
232
- }
233
- const exec = options.exec ?? execCommand;
234
- await run(exec, options.cwd, "git", ["push", "-u", "origin", options.branch]);
235
- const stdout = await gh(exec, options.cwd, ["pr", "create", "--base", options.base, "--head", options.branch, "--title", options.title, "--body", options.body.slice(0, 60_000)]);
236
- return parseCreated(stdout);
237
- }
238
-
239
196
  /** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
240
197
  export class PullsState {
241
198
  pulls: PullSummary[] = [];
@@ -20,7 +20,7 @@ import type { Domain } from "../schemas/agent.ts";
20
20
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
21
21
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
22
22
  import { checkThinking } from "../pi/model-support.ts";
23
- import { jobTitle, QuickFixQueue } from "./quickfix.ts";
23
+ import { QuickFixQueue } from "./quickfix.ts";
24
24
  import type { Asker } from "../ask/types.ts";
25
25
  import type { PlanningSession } from "./planner.ts";
26
26
  import { execCommand, IssuesState } from "./issues.ts";
@@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  import { realpathSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
13
+ import { taskWork } from "../state/work-time.ts";
13
14
  import { refreshReview, deferReview } from "../delivery/review.ts";
14
15
  import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
15
16
  import type { LockIdentity } from "../delivery/lock.ts";
@@ -34,7 +35,7 @@ import { pendingRequest, startRequest } from "../pi/route.ts";
34
35
  import { deliverComments, setAuto } from "../pi/owner.ts";
35
36
  import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
36
37
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
37
- import { launchPi, SessionRegistry, type BackgroundSession, type DialogAnswer, type SessionLauncher } from "./sessions.ts";
38
+ import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
38
39
  import { SessionChats } from "./session-files.ts";
39
40
  import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
40
41
  import { askUser } from "../ask/web.ts";
@@ -572,6 +573,7 @@ export function createLobbyService(state: Runtime): LobbyService {
572
573
  feed: lobbyFeed,
573
574
  masterBusy: () => !state.ctx.isIdle(),
574
575
  tasks: () => peekTasks(state.root, state.configDir),
576
+ work: (taskId) => taskWork(state.root, state.configDir, taskId),
575
577
  deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
576
578
  deliveryReview: async (taskId) => {
577
579
  const task = deliveryTask(state, taskId);
@@ -7,51 +7,7 @@
7
7
  * parent, and the text of user and assistant messages (never tool output).
8
8
  */
9
9
  import { closeSync, openSync, readSync, statSync } from "node:fs";
10
- import { chatFromEntries, chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
11
-
12
- interface EntryLike {
13
- type?: string;
14
- id?: string;
15
- parentId?: string | null;
16
- }
17
-
18
- /** The entries on the branch that ends at the newest entry, oldest first (a session file is a tree). */
19
- export function currentBranch(entries: readonly unknown[]): unknown[] {
20
- const byId = new Map<string, EntryLike>();
21
- let leaf: EntryLike | undefined;
22
- for (const entry of entries) {
23
- const record = entry as EntryLike;
24
- if (!record || typeof record.id !== "string") continue;
25
- byId.set(record.id, record);
26
- leaf = record;
27
- }
28
- const branch: unknown[] = [];
29
- const seen = new Set<string>();
30
- for (let entry = leaf; entry?.id && !seen.has(entry.id); entry = entry.parentId ? byId.get(entry.parentId) : undefined) {
31
- seen.add(entry.id);
32
- branch.push(entry);
33
- }
34
- return branch.reverse();
35
- }
36
-
37
- /** Parse a session file's JSON lines, skipping torn ones. */
38
- export function parseEntries(text: string): unknown[] {
39
- const entries: unknown[] = [];
40
- for (const line of text.split("\n")) {
41
- if (!line.trim()) continue;
42
- try {
43
- entries.push(JSON.parse(line));
44
- } catch {
45
- // A line being written right now; the next read has it.
46
- }
47
- }
48
- return entries;
49
- }
50
-
51
- /** The conversation a session file holds, as the lobby's chat entries. */
52
- export function chatFromFile(text: string, max = MAX_CHAT): ChatEntry[] {
53
- return chatFromEntries(currentBranch(parseEntries(text)), max).map((line, index) => ({ id: index + 1, at: line.at ?? 0, role: line.role, text: line.text }));
54
- }
10
+ import { chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
55
11
 
56
12
  /** What a session log keeps of one entry: its parent, and its conversation lines if it is a message. */
57
13
  interface LogNode {
@@ -202,7 +202,39 @@ function planWithSteps(plan: string, covers: readonly number[], stepCount: numbe
202
202
  const lines = plan.split("\n");
203
203
  const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
204
204
  const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
205
- return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
205
+ return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...stepDetails(lines.slice(found.to), covers)].join("\n");
206
+ }
207
+
208
+ /** `### Step 3: …` (or `**Step 3 — …**`): the heading of one step's detail. */
209
+ const DETAIL_HEADING = /^(\s*(?:#{1,6}\s+)?(?:\*\*)?\s*step\s+#?)(\d+)(?!\d)/i;
210
+ const ANY_HEADING = /^\s*(?:(#{1,6})\s+\S.*|\*\*[^*]+\*\*.*)$/;
211
+
212
+ /** A heading's depth: `#` is 1, a bold line deeper than any `#`. */
213
+ const depth = (line: string): number => ANY_HEADING.exec(line)?.[1]?.length ?? 7;
214
+
215
+ /**
216
+ * The lines after a plan's steps list with only the detail of the steps in `covers`, renumbered as
217
+ * the list was: a step's detail runs from its `Step N` heading to the next heading as deep or deeper.
218
+ */
219
+ function stepDetails(lines: readonly string[], covers: readonly number[]): string[] {
220
+ const out: string[] = [];
221
+ let skipping: number | undefined;
222
+ for (const line of lines) {
223
+ const heading = ANY_HEADING.test(line) ? depth(line) : undefined;
224
+ if (skipping !== undefined && heading !== undefined && heading <= skipping) skipping = undefined;
225
+ const detail = heading !== undefined ? DETAIL_HEADING.exec(line) : null;
226
+ if (detail) {
227
+ const at = covers.indexOf(Number(detail[2]));
228
+ if (at < 0) {
229
+ skipping = heading;
230
+ continue;
231
+ }
232
+ out.push(line.replace(DETAIL_HEADING, (_all, lead: string) => `${lead}${at + 1}`));
233
+ continue;
234
+ }
235
+ if (skipping === undefined) out.push(line);
236
+ }
237
+ return out;
206
238
  }
207
239
 
208
240
  /** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
@@ -22,6 +22,8 @@ export interface TaskRow {
22
22
  check: CheckState;
23
23
  /** Plan steps done, once the task has a plan. */
24
24
  progress?: { done: number; total: number };
25
+ /** How long the task's agents have worked on it, idle time left out (ms, as of the reply). */
26
+ work?: { workedMs: number; running: boolean };
25
27
  /** Who drives it, for tasks other sessions own. */
26
28
  owner?: string;
27
29
  /** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import type { Domain } from "../schemas/agent.ts";
4
3
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
@@ -39,21 +38,6 @@ export function saveResearchResult(taskDir: string, outcome: ResearchOutcome): v
39
38
  writeFileEnsured(researchResultPath(taskDir, outcome.result.domain), JSON.stringify(outcome, null, 2));
40
39
  }
41
40
 
42
- /** Re-read persisted research reports for the given domains. */
43
- export function loadResearchResults(taskDir: string, domains: Domain[]): ResearchOutcome[] {
44
- const outcomes: ResearchOutcome[] = [];
45
- for (const domain of domains) {
46
- const path = researchResultPath(taskDir, domain);
47
- if (!existsSync(path)) continue;
48
- try {
49
- outcomes.push(JSON.parse(readFileSync(path, "utf8")) as ResearchOutcome);
50
- } catch {
51
- // Corrupted research artifact: skip it rather than failing the workflow.
52
- }
53
- }
54
- return outcomes;
55
- }
56
-
57
41
  function researchContext(request: ResearchRequest): AgentRequest["context"] {
58
42
  return {
59
43
  task: request.instruction,
package/src/pi/events.ts CHANGED
@@ -17,7 +17,7 @@ import { isAutoMode } from "../state/auto.ts";
17
17
  import { triageContext } from "../classifier/triage.ts";
18
18
  import { qaStillDue } from "../workflow/track.ts";
19
19
  import { previousTaskNote } from "./fresh-context.ts";
20
- import { budgetLine, budgetState, pauseClocks, readBudget, resumeClocks, startClock, stopClocks } from "../state/budget.ts";
20
+ import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
21
21
 
22
22
  /** Tools that wait on the user: the task's clock waits with them. */
23
23
  const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
@@ -77,10 +77,11 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
77
77
  });
78
78
 
79
79
 
80
- // A task's time budget counts while the oracle works on it, not while it waits on the user.
80
+ // A task's work time (and its time budget) counts while the oracle works on it, not while it waits on the user.
81
81
  const asking = new Set<string>();
82
82
  pi.on("agent_start", (_event, ctx) => {
83
83
  if (isSubagentProcess()) return;
84
+ turnStarted();
84
85
  const root = detectProjectRoot(ctx.cwd, configDir);
85
86
  const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
86
87
  if (task) startClock(root, configDir, task.id);
@@ -92,9 +93,14 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
92
93
  pauseClocks();
93
94
  }
94
95
  });
95
- pi.on("tool_execution_end", (event) => {
96
+ pi.on("tool_execution_end", (event, ctx) => {
96
97
  if (isSubagentProcess()) return;
97
98
  if (asking.delete(event.toolCallId)) resumeClocks();
99
+ // A task started, finished or taken over within the turn: the clock follows the task the session drives now.
100
+ if (event.toolName === "orchestrate") {
101
+ const root = detectProjectRoot(ctx.cwd, configDir);
102
+ clockTask(root, configDir, activeTask(root, configDir, ctx.sessionManager.getSessionId())?.id);
103
+ }
98
104
  });
99
105
  pi.on("agent_end", () => {
100
106
  if (isSubagentProcess()) return;
@@ -18,7 +18,6 @@ import { describeKey, jevEndpoint, type StatusSource } from "../classifier/hosts
18
18
 
19
19
  /** One settings entry: the master or a subagent profile. */
20
20
  export type SettingsKind = "master" | SubagentKind;
21
- export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
22
21
 
23
22
  /** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
24
23
  export interface EntryView {