@a-t-h-i/bot-lobby 0.1.0 → 0.2.0
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.
- package/README.md +16 -4
- package/package.json +4 -2
- package/prompts/master.md +5 -0
- package/src/execution/pi-runner.ts +22 -10
- package/src/pi/activity.ts +3 -0
- package/src/pi/events.ts +25 -3
- package/src/pi/mascot-art.ts +48 -31
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +98 -16
- package/src/pi/zen-large.ts +95 -25
- package/src/pi/zen.ts +212 -52
- package/src/schemas/task.ts +18 -0
- package/src/workflow/workflow.ts +25 -1
package/README.md
CHANGED
|
@@ -117,10 +117,13 @@ While the owning session has a task active, its transcript switches to a zen vie
|
|
|
117
117
|
and the built-in spinner are hidden, and a widget above the editor animates the
|
|
118
118
|
task. At 72 columns and wider it draws a large scene: a header box with the task
|
|
119
119
|
title and state in its top border, a progress bar, and a metadata row with
|
|
120
|
-
elapsed time, quiet-mode hint and task id;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
elapsed time, quiet-mode hint and task id; an oracle tower with a twinkling
|
|
121
|
+
aura (drifting z's while dormant), a radiant orb crown, window eyes, a
|
|
122
|
+
seven-column mouth and its ORC door; beside the crown, the oracle's speech
|
|
123
|
+
bubble, its tail on the orb, says what the master is doing (`⠋ delegating`,
|
|
124
|
+
`⠋ thinking`, `· your turn` once its turn ends, `· dormant` when paused) above
|
|
125
|
+
who is at work (`→ DEV · QA`), the current step (`step 3 of 7`) or the task
|
|
126
|
+
phase (`awaiting your approval`);
|
|
124
127
|
four animated slots — DEV, DESIGN, RESEARCH and QA — each with a status face, a
|
|
125
128
|
caption and two status rows: while running, a braille spinner beside the agent's
|
|
126
129
|
live one-word activity (for example `⠋ reading` or `⠋ editing`) with its elapsed
|
|
@@ -138,6 +141,15 @@ expression plays — and their faces, colours and words follow each agent's stat
|
|
|
138
141
|
five-column ASCII eyes, while its emote frames are status-aware kaomoji: nervous
|
|
139
142
|
while working, happy when done (QA flexes and dances), scared on failure. The
|
|
140
143
|
header progress bar is plan-derived.
|
|
144
|
+
|
|
145
|
+
The checklist follows the workers through the plan. Plan steps are read from
|
|
146
|
+
`Step N` headings, a `Steps`/`Sequence`/`Order` section, or numbered lines, and
|
|
147
|
+
only top-level items count (sub-points nested under a step never inflate it).
|
|
148
|
+
Each worker instruction is matched to a step by an explicit label
|
|
149
|
+
(`Step 3: ...`, `steps 2-4`) or, failing that, by shared paths, its opening
|
|
150
|
+
phrase and word overlap, with near-ties going to the earliest open step so a
|
|
151
|
+
file path reused across steps cannot pin progress to step 1. Every worker
|
|
152
|
+
delegation is recorded on the task, so progress survives a reload.
|
|
141
153
|
New tasks get a <=3-word title derived from the request (for example "create
|
|
142
154
|
landing page") plus an id `TASK-<slug>` built from the full request, so the banner
|
|
143
155
|
and header stay concise; older `TASK-<timestamp>` tasks keep loading untouched.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a-t-h-i/bot-lobby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Structured multi-agent software engineering orchestrator for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"pi": {
|
|
26
|
-
"extensions": [
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
]
|
|
27
29
|
},
|
|
28
30
|
"scripts": {
|
|
29
31
|
"typecheck": "tsc --noEmit",
|
package/prompts/master.md
CHANGED
|
@@ -47,6 +47,11 @@ Assign work to the correct domain; never ask one domain to do another's. A
|
|
|
47
47
|
cross-domain dependency is reported to you, and you decide whether another
|
|
48
48
|
domain needs a task.
|
|
49
49
|
|
|
50
|
+
Write the plan's steps as a numbered list under a `## Steps` heading, and open
|
|
51
|
+
each `implement` task with its step number (`Step 3: ...`, or `Steps 3-4: ...`
|
|
52
|
+
when one delegation covers several) so the user's checklist tracks progress
|
|
53
|
+
exactly.
|
|
54
|
+
|
|
50
55
|
## Research
|
|
51
56
|
|
|
52
57
|
Summon the researcher with `orchestrate action=research` (a `domain` and an
|
|
@@ -77,9 +77,12 @@ export interface StreamCollector {
|
|
|
77
77
|
* them.
|
|
78
78
|
*/
|
|
79
79
|
export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void): StreamCollector {
|
|
80
|
-
|
|
80
|
+
const partial: string[] = [];
|
|
81
81
|
const kept: string[] = [];
|
|
82
82
|
const keep = (line: string) => {
|
|
83
|
+
// Most lines are per-token `message_update` deltas or large tool results;
|
|
84
|
+
// skip the parse for any line that cannot be one of the two kept events.
|
|
85
|
+
if (!line.includes('"tool_execution_start"') && !line.includes('"message_end"')) return;
|
|
83
86
|
let raw: unknown;
|
|
84
87
|
try {
|
|
85
88
|
raw = JSON.parse(line);
|
|
@@ -94,15 +97,21 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
|
|
|
94
97
|
if (event.type === "message_end" && event.message?.role === "assistant") kept.push(line);
|
|
95
98
|
};
|
|
96
99
|
return {
|
|
100
|
+
// Scan only the new chunk for line breaks; a long line split over many
|
|
101
|
+
// chunks is joined once, when its newline finally arrives.
|
|
97
102
|
push(chunk) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
let start = 0;
|
|
104
|
+
let newline = chunk.indexOf("\n");
|
|
105
|
+
while (newline >= 0) {
|
|
106
|
+
const tail = chunk.slice(start, newline);
|
|
107
|
+
keep(partial.length > 0 ? partial.splice(0).join("") + tail : tail);
|
|
108
|
+
start = newline + 1;
|
|
109
|
+
newline = chunk.indexOf("\n", start);
|
|
110
|
+
}
|
|
111
|
+
if (start < chunk.length) partial.push(chunk.slice(start));
|
|
102
112
|
},
|
|
103
113
|
finish() {
|
|
104
|
-
if (
|
|
105
|
-
pending = "";
|
|
114
|
+
if (partial.length > 0) keep(partial.splice(0).join(""));
|
|
106
115
|
return kept.join("\n");
|
|
107
116
|
},
|
|
108
117
|
};
|
|
@@ -199,10 +208,13 @@ function wireOutput(
|
|
|
199
208
|
onEvent?: (event: PiStreamEvent) => void,
|
|
200
209
|
): () => void {
|
|
201
210
|
const collector = createStreamCollector(onEvent);
|
|
202
|
-
|
|
203
|
-
proc.
|
|
211
|
+
// Decode as UTF-8 streams so a multi-byte character split across chunks survives.
|
|
212
|
+
proc.stdout?.setEncoding("utf8");
|
|
213
|
+
proc.stderr?.setEncoding("utf8");
|
|
214
|
+
proc.stdout?.on("data", (chunk: string) => collector.push(chunk));
|
|
215
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
204
216
|
// Keep stderr head-bounded: it only feeds exit-code error messages.
|
|
205
|
-
if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk
|
|
217
|
+
if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk;
|
|
206
218
|
});
|
|
207
219
|
return () => {
|
|
208
220
|
buffers.stdout = collector.finish();
|
package/src/pi/activity.ts
CHANGED
|
@@ -22,6 +22,9 @@ const ACTIVITY_WORDS: Record<string, string> = {
|
|
|
22
22
|
|
|
23
23
|
const FALLBACK_WORD = "working";
|
|
24
24
|
|
|
25
|
+
/** What the oracle says between its own tool calls while a master turn runs. */
|
|
26
|
+
export const ORACLE_THINKING = "thinking";
|
|
27
|
+
|
|
25
28
|
export function activityWord(toolName: string): string {
|
|
26
29
|
return ACTIVITY_WORDS[toolName.trim().toLowerCase()] ?? FALLBACK_WORD;
|
|
27
30
|
}
|
package/src/pi/events.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { cancelAllRuns } from "../execution/agent-runner.ts";
|
|
|
8
8
|
import { describeTask } from "../workflow/workflow.ts";
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
10
|
import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity } from "./ui.ts";
|
|
11
|
-
import { oracleActivityWord } from "./activity.ts";
|
|
11
|
+
import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
|
|
12
12
|
import { isSubagentProcess, visibleTools } from "./quiet.ts";
|
|
13
13
|
import { registerQuietTools } from "./tool-renderers.ts";
|
|
14
14
|
import { taskRequest, type Task } from "../schemas/task.ts";
|
|
@@ -43,9 +43,31 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
// The oracle
|
|
46
|
+
// The oracle's speech bubble mirrors the master's own turn: thinking between
|
|
47
|
+
// tool calls, the tool's word during one, and silent (your turn) once it ends.
|
|
48
|
+
// Subagents report into runs instead.
|
|
49
|
+
// Parallel tool calls: the newest still-running call keeps the word.
|
|
50
|
+
const inFlight = new Map<string, string>();
|
|
51
|
+
const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
|
|
52
|
+
pi.on("agent_start", () => {
|
|
53
|
+
if (isSubagentProcess()) return;
|
|
54
|
+
inFlight.clear();
|
|
55
|
+
showOracle();
|
|
56
|
+
});
|
|
47
57
|
pi.on("tool_execution_start", (event) => {
|
|
48
|
-
if (
|
|
58
|
+
if (isSubagentProcess()) return;
|
|
59
|
+
inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
|
|
60
|
+
showOracle();
|
|
61
|
+
});
|
|
62
|
+
pi.on("tool_execution_end", (event) => {
|
|
63
|
+
if (isSubagentProcess()) return;
|
|
64
|
+
inFlight.delete(event.toolCallId);
|
|
65
|
+
showOracle();
|
|
66
|
+
});
|
|
67
|
+
pi.on("agent_end", () => {
|
|
68
|
+
if (isSubagentProcess()) return;
|
|
69
|
+
inFlight.clear();
|
|
70
|
+
setOracleActivity(undefined);
|
|
49
71
|
});
|
|
50
72
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
51
73
|
cancelAllRuns();
|
package/src/pi/mascot-art.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { TaskState } from "../schemas/task.ts";
|
|
|
6
6
|
* that `zen.ts` and `zen-large.ts` compose. Every glyph is exactly one visible
|
|
7
7
|
* column so the art stays narrow/ambiguous-width safe: the non-ASCII glyphs are
|
|
8
8
|
* the lobby `⌂` in `BANNER_NARROW`, the panel's ◐/✓/✗ status family, and the
|
|
9
|
-
* box-drawing, bar and
|
|
9
|
+
* box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░ ▁ ▂ ▃ ◉ ◍ ◎ ◌ ○ ✦ ✧).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -105,7 +105,7 @@ export const ORACLE_COLORS: Record<OraclePose, { color: PanelColor; bold: boolea
|
|
|
105
105
|
dormant: { color: "dim", bold: false },
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
-
/** One oracle frame: the
|
|
108
|
+
/** One oracle frame: the crown orb, both window eyes and the seven-column mouth. */
|
|
109
109
|
export interface OracleFrame {
|
|
110
110
|
orb: string;
|
|
111
111
|
winL: string;
|
|
@@ -115,25 +115,38 @@ export interface OracleFrame {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote
|
|
119
|
-
*
|
|
120
|
-
*
|
|
118
|
+
* Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote. While
|
|
119
|
+
* orchestrating the oracle glances around (2) and speaks (3); dormant, it sleeps
|
|
120
|
+
* with closed eyes, peeks (1) and snores (2). Every glyph is one column and every
|
|
121
|
+
* mouth seven, so a frame swap never moves the tower geometry.
|
|
121
122
|
*/
|
|
122
123
|
export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
|
|
123
124
|
orchestrating: [
|
|
124
|
-
{ orb: "◉", winL: "◉", winR: "◉", mouth: "
|
|
125
|
-
{ orb: "◉", winL: "─", winR: "─", mouth: "
|
|
126
|
-
{ orb: "◎", winL: "◍", winR: "◉", mouth: "
|
|
127
|
-
{ orb: "◍", winL: "◉", winR: "◍", mouth: "
|
|
125
|
+
{ orb: "◉", winL: "◉", winR: "◉", mouth: " ╰───╯ " },
|
|
126
|
+
{ orb: "◉", winL: "─", winR: "─", mouth: " ╰───╯ " },
|
|
127
|
+
{ orb: "◎", winL: "◍", winR: "◉", mouth: " ─── " },
|
|
128
|
+
{ orb: "◍", winL: "◉", winR: "◍", mouth: " ▁▂▃▂▁ " },
|
|
128
129
|
],
|
|
129
130
|
dormant: [
|
|
130
|
-
{ orb: "◌", winL: "
|
|
131
|
-
{ orb: "◌", winL: "
|
|
132
|
-
{ orb: "○", winL: "
|
|
133
|
-
{ orb: "○", winL: "─", winR: "─", mouth: "
|
|
131
|
+
{ orb: "◌", winL: "─", winR: "─", mouth: " ─── " },
|
|
132
|
+
{ orb: "◌", winL: "◌", winR: "─", mouth: " ─── " },
|
|
133
|
+
{ orb: "○", winL: "─", winR: "─", mouth: " ─o─ " },
|
|
134
|
+
{ orb: "○", winL: "─", winR: "─", mouth: " ─── " },
|
|
134
135
|
],
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The seven-column aura above the crown, stepped by the spinner tick: sparkles
|
|
140
|
+
* twinkle while the oracle orchestrates, and z's drift up while it sleeps.
|
|
141
|
+
*/
|
|
142
|
+
export const ORACLE_AURA: Record<OraclePose, readonly string[]> = {
|
|
143
|
+
orchestrating: ["· ✦ ·", " ✧ · ✧ ", "✦ · ✦", " · ✧ · "],
|
|
144
|
+
dormant: [" z ", " z ", " z Z ", " Z "],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Ticks each aura frame holds: a calm twinkle rather than the spinner's pace. */
|
|
148
|
+
export const ORACLE_AURA_TICKS = 2;
|
|
149
|
+
|
|
137
150
|
/**
|
|
138
151
|
* One frame per status and expression: index 0 is the calm rest face, 1 the
|
|
139
152
|
* blink and 2-3 the emotes. Every status keeps the same frame count and one row
|
|
@@ -209,13 +222,14 @@ export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>
|
|
|
209
222
|
|
|
210
223
|
/**
|
|
211
224
|
* The oracle tower: 13 columns wide, `rows` while height allows and `smallRows`
|
|
212
|
-
* when it must shrink. `{
|
|
213
|
-
* window eyes and `{door}`
|
|
225
|
+
* when it must shrink. `{aura}` twinkles over the radiant `{orb}` crown,
|
|
226
|
+
* `{winL}`/`{winR}` are the two window eyes, `{mouth}` speaks and `{door}` is
|
|
227
|
+
* the name over the door. The speech bubble hangs beside the crown.
|
|
214
228
|
*/
|
|
215
229
|
export interface TowerSpec {
|
|
216
230
|
rows: readonly string[];
|
|
217
231
|
smallRows: readonly string[];
|
|
218
|
-
tokens: { orb: string; winL: string; winR: string; door: string; mouth: string };
|
|
232
|
+
tokens: { aura: string; orb: string; winL: string; winR: string; door: string; mouth: string };
|
|
219
233
|
}
|
|
220
234
|
|
|
221
235
|
export const TOWER_WIDTH = 13;
|
|
@@ -224,29 +238,32 @@ export const TOWER_DOOR = "ORC";
|
|
|
224
238
|
|
|
225
239
|
export const TOWER: TowerSpec = {
|
|
226
240
|
rows: [
|
|
227
|
-
"
|
|
228
|
-
"
|
|
229
|
-
"
|
|
241
|
+
" {aura} ",
|
|
242
|
+
" ╲ │ ╱ ",
|
|
243
|
+
" ──({orb})── ",
|
|
244
|
+
" ╱ │ ╲ ",
|
|
230
245
|
" │ ",
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"├───────────┤",
|
|
234
|
-
"│ ┌─┐ ┌─┐ │",
|
|
246
|
+
"╭─────┴─────╮",
|
|
247
|
+
"│ ╭─╮ ╭─╮ │",
|
|
235
248
|
"│ │{winL}│ │{winR}│ │",
|
|
236
|
-
"│
|
|
237
|
-
"├───────────┤",
|
|
249
|
+
"│ ╰─╯ ╰─╯ │",
|
|
238
250
|
"│ {mouth} │",
|
|
239
251
|
"├───┬───┬───┤",
|
|
240
|
-
"
|
|
252
|
+
"│░▒▓│{door}│▓▒░│",
|
|
241
253
|
"└───┴───┴───┘",
|
|
242
254
|
],
|
|
243
255
|
smallRows: [
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"│ ┌─┐ ┌─┐ │",
|
|
256
|
+
" ──({orb})── ",
|
|
257
|
+
"╭─────┴─────╮",
|
|
247
258
|
"│ │{winL}│ │{winR}│ │",
|
|
248
|
-
"
|
|
259
|
+
"│░▒▓│{door}│▓▒░│",
|
|
249
260
|
"└───┴───┴───┘",
|
|
250
261
|
],
|
|
251
|
-
tokens: { orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
|
|
262
|
+
tokens: { aura: "{aura}", orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
|
|
252
263
|
};
|
|
264
|
+
|
|
265
|
+
/** The oracle's speech bubble: outer width including borders, and its text rows. */
|
|
266
|
+
export const BUBBLE = { width: 24, lines: 2 } as const;
|
|
267
|
+
|
|
268
|
+
/** What the oracle says while the master waits on the user between turns. */
|
|
269
|
+
export const ORACLE_IDLE_WORD = "your turn";
|
package/src/pi/tools.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ProcessRunner } from "../execution/pi-runner.ts";
|
|
|
7
7
|
import { inheritThinking } from "../schemas/configuration.ts";
|
|
8
8
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
|
-
import { applyStatus, summarizeRun } from "./ui.ts";
|
|
10
|
+
import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
|
|
11
11
|
import { isQuiet } from "./quiet.ts";
|
|
12
12
|
import {
|
|
13
13
|
ORCHESTRATE_ACTIONS,
|
|
@@ -128,7 +128,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
|
|
|
128
128
|
renderShell: "self",
|
|
129
129
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
130
130
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
131
|
-
const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) =>
|
|
131
|
+
const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess, pi.getThinkingLevel());
|
|
132
132
|
const result = await runWorkflowAction(params as OrchestrateParams, deps);
|
|
133
133
|
if (params.action === "propose" && params.proposal && result.ok) {
|
|
134
134
|
pi.appendEntry("bot-lobby", { kind: "proposal", taskId: params.taskId, text: params.proposal });
|
package/src/pi/ui.ts
CHANGED
|
@@ -26,18 +26,36 @@ export function statusText(task: Task | undefined, minimized = false): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
let zenOn = false;
|
|
29
|
-
let zenState: { task: Task | undefined; runs: AgentRun[] } = { task: undefined, runs: [] };
|
|
30
29
|
|
|
31
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Widget state. `live` holds the runs reported in this session; `runs` is what the
|
|
32
|
+
* panel draws: the task's persisted worker records overlaid by the live copies,
|
|
33
|
+
* so the checklist replays after a reload. `version` bumps on every change and
|
|
34
|
+
* keys the widget's render cache.
|
|
35
|
+
*/
|
|
36
|
+
let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
|
|
37
|
+
let zenVersion = 0;
|
|
38
|
+
|
|
39
|
+
/** Latest master tool activity; the oracle's speech bubble shows it. */
|
|
32
40
|
let oracleActivity: string | undefined;
|
|
33
41
|
|
|
34
|
-
/**
|
|
42
|
+
/** The mounted widget, so run and activity updates can repaint without waiting a tick. */
|
|
43
|
+
let mountedWidget: { refresh(): void } | undefined;
|
|
44
|
+
|
|
45
|
+
function touch(): void {
|
|
46
|
+
zenVersion += 1;
|
|
47
|
+
mountedWidget?.refresh();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
|
|
35
51
|
export function setOracleActivity(activity: string | undefined): void {
|
|
52
|
+
if (activity === oracleActivity) return;
|
|
36
53
|
oracleActivity = activity;
|
|
54
|
+
touch();
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
/** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
|
|
40
|
-
export const MAX_RETAINED_RUNS =
|
|
58
|
+
export const MAX_RETAINED_RUNS = 128;
|
|
41
59
|
|
|
42
60
|
/**
|
|
43
61
|
* Merge `incoming` runs into `previous`, keyed by `runId`: a newer copy of a run
|
|
@@ -46,13 +64,39 @@ export const MAX_RETAINED_RUNS = 64;
|
|
|
46
64
|
*/
|
|
47
65
|
export function mergeRuns(previous: readonly AgentRun[], incoming: readonly AgentRun[]): AgentRun[] {
|
|
48
66
|
if (incoming.length === 0) return [...previous];
|
|
49
|
-
const merged =
|
|
67
|
+
const merged = new Map<string, AgentRun>();
|
|
68
|
+
for (const run of previous) merged.set(run.runId, run);
|
|
50
69
|
for (const run of incoming) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
merged.push(run);
|
|
70
|
+
merged.delete(run.runId);
|
|
71
|
+
merged.set(run.runId, run);
|
|
54
72
|
}
|
|
55
|
-
return merged.slice(-MAX_RETAINED_RUNS);
|
|
73
|
+
return [...merged.values()].slice(-MAX_RETAINED_RUNS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The task's persisted worker records as runs, oldest first; the panel only reads their plan fields. */
|
|
77
|
+
export function persistedRuns(task: Task | undefined): AgentRun[] {
|
|
78
|
+
return (task?.workerRuns ?? []).map((record) => ({
|
|
79
|
+
runId: record.runId,
|
|
80
|
+
taskId: task!.id,
|
|
81
|
+
domain: record.domain,
|
|
82
|
+
role: "worker",
|
|
83
|
+
status: record.status,
|
|
84
|
+
instruction: record.instruction,
|
|
85
|
+
output: "",
|
|
86
|
+
attempts: 1,
|
|
87
|
+
startedAt: record.startedAt,
|
|
88
|
+
...(record.finishedAt ? { finishedAt: record.finishedAt } : {}),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Runs are retained without their report text: the panel never reads it and reports can be large. */
|
|
93
|
+
function slim(runs: readonly AgentRun[]): AgentRun[] {
|
|
94
|
+
return runs.map((run) => (run.output ? { ...run, output: "" } : run));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setZenState(task: Task | undefined, live: AgentRun[]): void {
|
|
98
|
+
zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
|
|
99
|
+
touch();
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
/** Per-session standard-pi mode: the widget and Master prompt are hidden but ownership stays. */
|
|
@@ -69,7 +113,7 @@ export function setMinimized(value: boolean): void {
|
|
|
69
113
|
/** Flip minimize/restore and refresh the footer; the session is unchanged. */
|
|
70
114
|
export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
|
|
71
115
|
setMinimized(!minimized);
|
|
72
|
-
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.
|
|
116
|
+
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
|
|
73
117
|
ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
|
|
74
118
|
}
|
|
75
119
|
|
|
@@ -100,6 +144,7 @@ const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
|
|
|
100
144
|
/** Animated zen scene + plan checklist shown above the editor while a task is active. */
|
|
101
145
|
class ZenWidget implements Component {
|
|
102
146
|
private tick = 0;
|
|
147
|
+
private cache: { key: string; theme: Theme; lines: string[] } | undefined;
|
|
103
148
|
private delay = liveTickDelay();
|
|
104
149
|
private timer: ReturnType<typeof setInterval>;
|
|
105
150
|
private disposed = false;
|
|
@@ -116,6 +161,12 @@ class ZenWidget implements Component {
|
|
|
116
161
|
const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng)] as const);
|
|
117
162
|
this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
|
|
118
163
|
this.timer = setInterval(() => this.advance(), this.delay);
|
|
164
|
+
mountedWidget = this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Repaint now: state changed between ticks. */
|
|
168
|
+
refresh(): void {
|
|
169
|
+
if (!this.disposed) this.tui.requestRender();
|
|
119
170
|
}
|
|
120
171
|
|
|
121
172
|
private advance(): void {
|
|
@@ -144,18 +195,34 @@ class ZenWidget implements Component {
|
|
|
144
195
|
return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
|
|
145
196
|
}
|
|
146
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The panel only changes with the tick, an expression frame, the widget state or
|
|
200
|
+
* the elapsed second, so every other repaint (typing in the editor, streaming
|
|
201
|
+
* output) reuses the last lines instead of recomposing the scene.
|
|
202
|
+
*/
|
|
147
203
|
render(width: number): string[] {
|
|
148
204
|
const now = Date.now();
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
205
|
+
const rows = this.tui.terminal.rows;
|
|
206
|
+
const theme = this.theme();
|
|
207
|
+
const expressions = this.frames();
|
|
208
|
+
const quiet = isQuiet();
|
|
209
|
+
const frameKey = EXPRESSION_KEYS.map((key) => expressions[key] ?? 0).join(",");
|
|
210
|
+
const key = `${width}|${rows}|${this.tick}|${frameKey}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
|
|
211
|
+
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
212
|
+
const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity };
|
|
213
|
+
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
|
|
214
|
+
this.cache = { key, theme, lines };
|
|
215
|
+
return lines;
|
|
152
216
|
}
|
|
153
217
|
|
|
154
|
-
invalidate(): void {
|
|
218
|
+
invalidate(): void {
|
|
219
|
+
this.cache = undefined;
|
|
220
|
+
}
|
|
155
221
|
|
|
156
222
|
dispose(): void {
|
|
157
223
|
this.disposed = true;
|
|
158
224
|
clearInterval(this.timer);
|
|
225
|
+
if (mountedWidget === this) mountedWidget = undefined;
|
|
159
226
|
}
|
|
160
227
|
}
|
|
161
228
|
|
|
@@ -176,7 +243,7 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
|
|
|
176
243
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
177
244
|
const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
|
|
178
245
|
const sameTask = zenState.task?.id === task?.id;
|
|
179
|
-
|
|
246
|
+
setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
|
|
180
247
|
ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
|
|
181
248
|
const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
|
|
182
249
|
if (!active) {
|
|
@@ -192,9 +259,24 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
|
|
|
192
259
|
}
|
|
193
260
|
}
|
|
194
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Streamed run updates (start, every activity change, finish) from an in-flight
|
|
264
|
+
* `orchestrate` call. The task on disk does not change mid-call, so this only
|
|
265
|
+
* merges the runs and repaints; `applyStatus` rereads the task once the call ends.
|
|
266
|
+
* Before any task is loaded it falls back to `applyStatus` so the widget appears.
|
|
267
|
+
*/
|
|
268
|
+
export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
|
|
269
|
+
if (!zenState.task && !minimized) {
|
|
270
|
+
applyStatus(ctx, root, configDir, runs);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
setZenState(zenState.task, mergeRuns(zenState.live, slim(runs)));
|
|
274
|
+
}
|
|
275
|
+
|
|
195
276
|
export function clearStatus(ctx: ExtensionContext): void {
|
|
196
277
|
leaveZen(ctx);
|
|
197
278
|
oracleActivity = undefined;
|
|
279
|
+
zenState = { task: undefined, live: [], runs: [] };
|
|
198
280
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
199
281
|
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
200
282
|
}
|
|
@@ -217,7 +299,7 @@ function revealTools(ctx: ExtensionContext, configDir: string): void {
|
|
|
217
299
|
const expanded = ctx.ui.getToolsExpanded();
|
|
218
300
|
ctx.ui.setToolsExpanded(!expanded);
|
|
219
301
|
ctx.ui.setToolsExpanded(expanded);
|
|
220
|
-
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.
|
|
302
|
+
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
|
|
221
303
|
ctx.ui.notify(
|
|
222
304
|
quiet
|
|
223
305
|
? "bot-lobby: tool rows hidden from now on — alt+t reveals them"
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import {
|
|
3
3
|
BAR,
|
|
4
|
+
BUBBLE,
|
|
5
|
+
ORACLE_AURA,
|
|
6
|
+
ORACLE_AURA_TICKS,
|
|
4
7
|
ORACLE_COLORS,
|
|
8
|
+
ORACLE_IDLE_WORD,
|
|
5
9
|
ORACLE_FRAMES,
|
|
6
10
|
ORACLE_WORDS,
|
|
7
11
|
SLOT_FRAMES,
|
|
@@ -12,6 +16,7 @@ import {
|
|
|
12
16
|
SPIN_FRAMES,
|
|
13
17
|
TOWER,
|
|
14
18
|
TOWER_DOOR,
|
|
19
|
+
TOWER_WIDTH,
|
|
15
20
|
type OracleFrame,
|
|
16
21
|
type OraclePose,
|
|
17
22
|
type PanelColor,
|
|
@@ -21,8 +26,8 @@ import {
|
|
|
21
26
|
import type { PanelTheme } from "./zen.ts";
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
|
-
* The large animated zen scene: a header box, the
|
|
25
|
-
* tree connector, the four animated agent columns (face, label, status word and
|
|
29
|
+
* The large animated zen scene: a header box, the oracle tower with its speech
|
|
30
|
+
* bubble and tree connector, the four animated agent columns (face, label, status word and
|
|
26
31
|
* elapsed rows), and the TASKS checklist spanning the full scene width (there is
|
|
27
32
|
* no LOG window).
|
|
28
33
|
*
|
|
@@ -72,8 +77,10 @@ export interface LargeSceneInput {
|
|
|
72
77
|
slots: readonly LargeSlot[];
|
|
73
78
|
tasks: readonly LargeTaskRow[];
|
|
74
79
|
oracle: { pose: OraclePose; frame: number };
|
|
75
|
-
/** Master's live
|
|
80
|
+
/** Master's live activity for the speech bubble; absent means it waits on the user. */
|
|
76
81
|
oracleActivity?: string;
|
|
82
|
+
/** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
|
|
83
|
+
caption?: string;
|
|
77
84
|
/** Approval/blocked line; never dropped when present. */
|
|
78
85
|
alert?: string;
|
|
79
86
|
/** Severity of the alert line: approvals are `warning`, blocked is `error`. */
|
|
@@ -98,11 +105,15 @@ const TASK_COLORS: Record<LargeTaskRow["status"], PanelColor> = {
|
|
|
98
105
|
};
|
|
99
106
|
|
|
100
107
|
const TOWER_ROWS: Record<TowerSize, number> = {
|
|
101
|
-
full: TOWER.rows.length
|
|
102
|
-
small: TOWER.smallRows.length
|
|
108
|
+
full: TOWER.rows.length,
|
|
109
|
+
small: TOWER.smallRows.length,
|
|
103
110
|
none: 0,
|
|
104
111
|
};
|
|
105
112
|
|
|
113
|
+
/** Bubble rows: top border, the text lines and the bottom border. */
|
|
114
|
+
const BUBBLE_ROWS = BUBBLE.lines + 2;
|
|
115
|
+
const BUBBLE_TEXT = BUBBLE.width - 4;
|
|
116
|
+
|
|
106
117
|
const TOWER_PATTERN = new RegExp(
|
|
107
118
|
`(${Object.values(TOWER.tokens).map((token) => token.replace(/([{}])/g, "\\$1")).join("|")})`,
|
|
108
119
|
);
|
|
@@ -299,40 +310,99 @@ function oracleFrame(input: LargeSceneInput): OracleFrame {
|
|
|
299
310
|
return frames[mod(input.oracle.frame, frames.length)]!;
|
|
300
311
|
}
|
|
301
312
|
|
|
313
|
+
function oracleAura(input: LargeSceneInput): string {
|
|
314
|
+
const frames = ORACLE_AURA[input.oracle.pose];
|
|
315
|
+
return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
|
|
316
|
+
}
|
|
317
|
+
|
|
302
318
|
function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
|
|
303
319
|
const frame = oracleFrame(input);
|
|
304
|
-
const values = new Map<string, string>([
|
|
305
|
-
[TOWER.tokens.orb, frame.orb],
|
|
306
|
-
[TOWER.tokens.winL, frame.winL],
|
|
307
|
-
[TOWER.tokens.winR, frame.winR],
|
|
308
|
-
[TOWER.tokens.mouth, frame.mouth],
|
|
309
|
-
[TOWER.tokens.door, input.doorLabel ?? TOWER_DOOR],
|
|
310
|
-
]);
|
|
311
320
|
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
321
|
+
const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
|
|
322
|
+
const values = new Map<string, () => string>([
|
|
323
|
+
[TOWER.tokens.aura, () => paint(oracleAura(input), accent.color, theme)],
|
|
324
|
+
[TOWER.tokens.orb, () => glow(frame.orb)],
|
|
325
|
+
[TOWER.tokens.winL, () => glow(frame.winL)],
|
|
326
|
+
[TOWER.tokens.winR, () => glow(frame.winR)],
|
|
327
|
+
[TOWER.tokens.mouth, () => glow(frame.mouth)],
|
|
328
|
+
[TOWER.tokens.door, () => glow(input.doorLabel ?? TOWER_DOOR)],
|
|
329
|
+
]);
|
|
330
|
+
return row
|
|
331
|
+
.split(TOWER_PATTERN)
|
|
332
|
+
.map((part) => values.get(part)?.() ?? paint(part, "muted", theme))
|
|
318
333
|
.join("");
|
|
319
334
|
}
|
|
320
335
|
|
|
321
|
-
/** The
|
|
322
|
-
function
|
|
336
|
+
/** The bubble's first line: what the oracle is doing right now. */
|
|
337
|
+
export function oracleSpeech(input: LargeSceneInput): string {
|
|
338
|
+
if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
|
|
339
|
+
const spin = SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!;
|
|
340
|
+
if (input.oracleActivity) return `${spin} ${input.oracleActivity}`;
|
|
341
|
+
if (workingLabels(input).length > 0) return `${spin} ${ORACLE_WORDS.orchestrating}`;
|
|
342
|
+
return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_IDLE_WORD}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function workingLabels(input: LargeSceneInput): string[] {
|
|
346
|
+
return input.slots.filter((slot) => slot.status === "working").map((slot) => slot.label || SLOT_LABELS[slot.id]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The bubble's second line: who is at work, else plan progress while building, else the task phase. */
|
|
350
|
+
export function oracleAside(input: LargeSceneInput): string {
|
|
351
|
+
const working = workingLabels(input);
|
|
352
|
+
if (input.oracle.pose === "orchestrating" && working.length > 0) return `→ ${working.join(" · ")}`;
|
|
353
|
+
const building = /^(implementing|reviewing)\b/.test(input.state);
|
|
354
|
+
if (building && input.total > 0) {
|
|
355
|
+
return input.done >= input.total ? "all steps done" : `step ${Math.min(input.done + 1, input.total)} of ${input.total}`;
|
|
356
|
+
}
|
|
357
|
+
return input.caption ?? input.state;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The speech bubble beside the crown: a rounded box with `BUBBLE.lines` text
|
|
362
|
+
* rows. The first text row opens with `┤`, where the tail from the oracle lands.
|
|
363
|
+
*/
|
|
364
|
+
function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
|
|
323
365
|
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
324
|
-
|
|
366
|
+
const border = (text: string) => paint(text, "muted", theme);
|
|
367
|
+
const text = (value: string) => padTo(truncateToWidth(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
|
|
368
|
+
const rule = "─".repeat(BUBBLE.width - 2);
|
|
369
|
+
return [
|
|
370
|
+
border(`╭${rule}╮`),
|
|
371
|
+
border("┤ ") + paint(text(oracleSpeech(input)), accent.color, theme, accent.bold) + border(" │"),
|
|
372
|
+
border("│ ") + paint(text(oracleAside(input)), "muted", theme) + border(" │"),
|
|
373
|
+
border(`╰${rule}╯`),
|
|
374
|
+
];
|
|
325
375
|
}
|
|
326
376
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
377
|
+
/** Row where the bubble starts: its tail row lines up with the crown orb where there is room. */
|
|
378
|
+
function bubbleStart(rows: readonly string[]): number {
|
|
379
|
+
const orb = rows.findIndex((row) => row.includes(TOWER.tokens.orb));
|
|
380
|
+
return clamp(orb - 1, 0, Math.max(0, rows.length - BUBBLE_ROWS));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The tail from the oracle to the bubble: the tower row's trailing blanks plus
|
|
385
|
+
* the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
|
|
386
|
+
*/
|
|
387
|
+
function tailRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
|
|
388
|
+
const body = row.replace(/ +$/, "");
|
|
389
|
+
const pad = row.length - body.length;
|
|
390
|
+
const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
|
|
391
|
+
return towerRow(body, input, theme) + paint(tail, "muted", theme);
|
|
330
392
|
}
|
|
331
393
|
|
|
332
394
|
function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
|
|
333
395
|
if (size === "none") return [];
|
|
334
396
|
const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
|
|
335
|
-
|
|
397
|
+
const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
|
|
398
|
+
const bubble = bubbleRows(input, theme);
|
|
399
|
+
const start = bubbleStart(rows);
|
|
400
|
+
return rows.map((row, index) => {
|
|
401
|
+
const speech = bubble[index - start];
|
|
402
|
+
if (speech === undefined) return left + towerRow(row, input, theme);
|
|
403
|
+
const tower = index === start + 1 ? tailRow(row, input, theme) : `${towerRow(row, input, theme)} `;
|
|
404
|
+
return left + tower + speech;
|
|
405
|
+
});
|
|
336
406
|
}
|
|
337
407
|
|
|
338
408
|
function stripWidth(count: number): number {
|
package/src/pi/zen.ts
CHANGED
|
@@ -67,9 +67,12 @@ const PREFIX_CHARS = 32;
|
|
|
67
67
|
const MIN_PREFIX = 8;
|
|
68
68
|
const HEADER_LINE = /^\s*(?:#{1,6}\s+\S.*|\*\*[^*]+\*\*)\s*$/;
|
|
69
69
|
const STEP_SECTION = /sequence|steps|order/i;
|
|
70
|
-
const NUMBERED_STEP_LINE =
|
|
71
|
-
const
|
|
72
|
-
const TOP_LEVEL_BULLET = /^[*-]\s+(.*\S)\s*$/;
|
|
70
|
+
const NUMBERED_STEP_LINE = /^(\s*)(\d+[.)])\s+(.*\S)\s*$/;
|
|
71
|
+
const BULLET_LINE = /^(\s*)([*-])\s+(.*\S)\s*$/;
|
|
72
|
+
const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
|
|
73
|
+
/** `### Step 2: Wire the API`, `**Step 2 — Wire the API**`, `Phase 3) Tests`: one plan step per heading. */
|
|
74
|
+
const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
|
|
75
|
+
const MIN_STEP_HEADINGS = 2;
|
|
73
76
|
|
|
74
77
|
export type PlanStepStatus = "done" | "current" | "pending";
|
|
75
78
|
|
|
@@ -78,20 +81,54 @@ export interface PlanStep {
|
|
|
78
81
|
status: PlanStepStatus;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
interface ListItem {
|
|
85
|
+
indent: number;
|
|
86
|
+
/** Column where the item's text starts; deeper items are nested under it. */
|
|
87
|
+
content: number;
|
|
88
|
+
text: string;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
type ItemMatcher = (line: string) => ListItem | undefined;
|
|
92
|
+
|
|
93
|
+
function indentOf(line: string): number {
|
|
94
|
+
return /^\s*/.exec(line)![0].replace(/\t/g, " ").length;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
function
|
|
97
|
+
function itemMatcher(pattern: RegExp): ItemMatcher {
|
|
98
|
+
return (line) => {
|
|
99
|
+
const match = pattern.exec(line);
|
|
100
|
+
if (!match) return undefined;
|
|
101
|
+
const indent = indentOf(match[1]!);
|
|
102
|
+
return { indent, content: indent + match[2]!.length + 1, text: match[3]! };
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const numberedItem = itemMatcher(NUMBERED_STEP_LINE);
|
|
107
|
+
const bulletItem = itemMatcher(BULLET_LINE);
|
|
108
|
+
const topBulletItem = itemMatcher(TOP_LEVEL_BULLET);
|
|
109
|
+
|
|
110
|
+
/** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
|
|
111
|
+
function sectionItem(line: string): ListItem | undefined {
|
|
112
|
+
return numberedItem(line) ?? bulletItem(line);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Top-level list items only. As in CommonMark, an item indented to its parent's
|
|
117
|
+
* text column is a sub-point of that step, so nested bullets or a nested `1.`
|
|
118
|
+
* list never inflate the checklist. Prose at a shallower indent ends the parent.
|
|
119
|
+
*/
|
|
120
|
+
function collectSteps(lines: readonly string[], accept: ItemMatcher): string[] {
|
|
91
121
|
const found: string[] = [];
|
|
122
|
+
let parent: number | undefined;
|
|
92
123
|
for (const line of lines) {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
124
|
+
const item = accept(line);
|
|
125
|
+
if (!item) {
|
|
126
|
+
if (parent !== undefined && line.trim() && indentOf(line) < parent) parent = undefined;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (parent !== undefined && item.indent >= parent) continue;
|
|
130
|
+
found.push(item.text);
|
|
131
|
+
parent = item.content;
|
|
95
132
|
}
|
|
96
133
|
return found;
|
|
97
134
|
}
|
|
@@ -108,37 +145,143 @@ function stepSection(lines: readonly string[]): string[] | undefined {
|
|
|
108
145
|
return body;
|
|
109
146
|
}
|
|
110
147
|
|
|
148
|
+
/** `Step N` headings, when the plan is structured as one heading per step. */
|
|
149
|
+
function headingSteps(lines: readonly string[]): string[] {
|
|
150
|
+
const found: string[] = [];
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
const match = STEP_HEADING.exec(line);
|
|
153
|
+
if (match) found.push(match[1] || line.replace(/[#*]/g, "").trim());
|
|
154
|
+
}
|
|
155
|
+
return found.length >= MIN_STEP_HEADINGS ? found : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
/**
|
|
112
|
-
* Step texts from a free-form plan, capped.
|
|
113
|
-
* numbered lines
|
|
114
|
-
*
|
|
159
|
+
* Step texts from a free-form plan, capped. `Step N` headings win; then a
|
|
160
|
+
* `sequence|steps|order` section; then numbered lines; when none exists,
|
|
161
|
+
* top-level bullets are the last resort, so unrelated bullet lists under other
|
|
162
|
+
* headers never leak into the checklist. Only the shallowest items count.
|
|
115
163
|
*/
|
|
116
164
|
export function planSteps(plan: string): string[] {
|
|
117
165
|
const lines = plan.split("\n");
|
|
166
|
+
const headings = headingSteps(lines);
|
|
167
|
+
if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
|
|
118
168
|
const section = stepSection(lines);
|
|
119
169
|
if (section) {
|
|
120
|
-
const sectioned = collectSteps(section,
|
|
170
|
+
const sectioned = collectSteps(section, sectionItem);
|
|
121
171
|
if (sectioned.length > 0) return sectioned.slice(0, MAX_PLAN_STEPS);
|
|
122
172
|
}
|
|
123
|
-
const numbered = collectSteps(lines,
|
|
173
|
+
const numbered = collectSteps(lines, numberedItem);
|
|
124
174
|
if (numbered.length > 0) return numbered.slice(0, MAX_PLAN_STEPS);
|
|
125
|
-
return collectSteps(lines,
|
|
175
|
+
return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
|
|
126
176
|
}
|
|
127
177
|
|
|
178
|
+
/* -------------------------------------------------------------------------
|
|
179
|
+
* Step matching. A worker instruction names its step explicitly ("Step 3: ...")
|
|
180
|
+
* or is scored against every step by shared paths, a shared opening phrase and
|
|
181
|
+
* word overlap. Plans reuse file paths across steps, so near-ties go to the
|
|
182
|
+
* earliest step still open instead of the first step that ever mentioned the
|
|
183
|
+
* path -- otherwise every later instruction re-matches step 1 and the tracker
|
|
184
|
+
* never moves.
|
|
185
|
+
* ---------------------------------------------------------------------- */
|
|
186
|
+
|
|
187
|
+
const STEP_REFERENCE = /\bsteps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|through|thru|and|&)\s*#?\s*(\d+))?/gi;
|
|
188
|
+
/** Text allowed before a step reference that labels the instruction itself ("Now implement step 3:"). */
|
|
189
|
+
const LEADING_LABEL = /^[\s\W]*(?:(?:now|next|then|please|implement|do|complete|execute|start|begin|finish|work on|continue with|proceed with|plan)\s+)*(?:the\s+)?$/i;
|
|
190
|
+
const MIN_SCORE = 0.5;
|
|
191
|
+
const NEAR_TIE = 0.35;
|
|
192
|
+
const PATH_WEIGHT = 0.75;
|
|
193
|
+
const PREFIX_WEIGHT = 1;
|
|
194
|
+
const WORD = /[a-z0-9_][a-z0-9_./-]*[a-z0-9_]/g;
|
|
195
|
+
const MIN_WORD = 3;
|
|
196
|
+
const STOP_WORDS = new Set([
|
|
197
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "then", "when", "each", "use", "make",
|
|
198
|
+
"sure", "new", "all", "any", "its", "are", "not", "but", "now", "via", "per", "our", "your", "you",
|
|
199
|
+
"has", "have", "will", "should", "must", "also", "only", "step", "steps", "plan", "approved",
|
|
200
|
+
"implement", "please", "add", "update", "change", "changes", "file", "files", "code",
|
|
201
|
+
]);
|
|
202
|
+
|
|
128
203
|
function normalize(text: string): string {
|
|
129
204
|
return text.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
130
205
|
}
|
|
131
206
|
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
207
|
+
function significantWords(text: string): Set<string> {
|
|
208
|
+
const words = new Set<string>();
|
|
209
|
+
for (const word of normalize(text).match(WORD) ?? []) {
|
|
210
|
+
if (word.length >= MIN_WORD && !STOP_WORDS.has(word)) words.add(word);
|
|
211
|
+
}
|
|
212
|
+
return words;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Scoring context for one instruction, built once per run and reused for every step. */
|
|
216
|
+
interface InstructionIndex {
|
|
217
|
+
text: string;
|
|
218
|
+
words: Set<string>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function indexInstruction(instruction: string): InstructionIndex {
|
|
222
|
+
return { text: normalize(instruction), words: significantWords(instruction) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function prefixMatches(step: string, hay: string): boolean {
|
|
226
|
+
const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.\u2014\u2013-]+/, ""));
|
|
137
227
|
return tail.length >= MIN_PREFIX && hay.includes(tail.slice(0, PREFIX_CHARS));
|
|
138
228
|
}
|
|
139
229
|
|
|
230
|
+
function pathShare(step: string, hay: string): number {
|
|
231
|
+
const paths = [...step.matchAll(/`([^`]+)`/g)].map((match) => normalize(match[1]!)).filter((path) => path.length > 0);
|
|
232
|
+
if (paths.length === 0) return 0;
|
|
233
|
+
return paths.filter((path) => hay.includes(path)).length / paths.length;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function wordShare(step: string, words: ReadonlySet<string>): number {
|
|
237
|
+
const own = significantWords(step);
|
|
238
|
+
if (own.size === 0) return 0;
|
|
239
|
+
let shared = 0;
|
|
240
|
+
for (const word of own) if (words.has(word)) shared += 1;
|
|
241
|
+
return shared / own.size;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** How strongly an instruction targets one step; 0 when it shares nothing. */
|
|
245
|
+
function stepScore(step: string, instruction: InstructionIndex): number {
|
|
246
|
+
const prefix = prefixMatches(step, instruction.text) ? PREFIX_WEIGHT : 0;
|
|
247
|
+
return prefix + PATH_WEIGHT * pathShare(step, instruction.text) + wordShare(step, instruction.words);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Highest 0-based step an instruction labels itself with ("Step 3: ...", "steps 2-4"),
|
|
252
|
+
* or -1. A reference counts when it opens the instruction or is the only one
|
|
253
|
+
* named, so "building on step 1, now do step 3" does not jump back to step 1.
|
|
254
|
+
*/
|
|
255
|
+
export function explicitStepIndex(instruction: string, count: number): number {
|
|
256
|
+
const refs = [...instruction.matchAll(STEP_REFERENCE)];
|
|
257
|
+
if (refs.length === 0) return -1;
|
|
258
|
+
const first = refs[0]!;
|
|
259
|
+
const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
|
|
260
|
+
const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
|
|
261
|
+
const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
|
|
262
|
+
if (!chosen) return -1;
|
|
263
|
+
const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
|
|
264
|
+
return last >= 1 && last <= count ? last - 1 : -1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The step an instruction targets, given the steps already completed; -1 when
|
|
269
|
+
* nothing matches. Among near-tied candidates the earliest open step wins.
|
|
270
|
+
*/
|
|
271
|
+
export function targetStep(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number> = new Set()): number {
|
|
272
|
+
if (!instruction || steps.length === 0) return -1;
|
|
273
|
+
const explicit = explicitStepIndex(instruction, steps.length);
|
|
274
|
+
if (explicit >= 0) return explicit;
|
|
275
|
+
const index = indexInstruction(instruction);
|
|
276
|
+
const scores = steps.map((step) => stepScore(step, index));
|
|
277
|
+
const best = Math.max(...scores);
|
|
278
|
+
if (best < MIN_SCORE) return -1;
|
|
279
|
+
const near = scores.findIndex((score, at) => score >= best - NEAR_TIE && score >= MIN_SCORE && !completed.has(at));
|
|
280
|
+
return near >= 0 ? near : scores.indexOf(best);
|
|
281
|
+
}
|
|
282
|
+
|
|
140
283
|
/** The latest worker run carrying an instruction; its step is the current one. */
|
|
141
|
-
export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
|
|
284
|
+
export function latestWorkerRun(runs: readonly AgentRun[]): AgentRun | undefined {
|
|
142
285
|
let latest: AgentRun | undefined;
|
|
143
286
|
for (const run of runs) {
|
|
144
287
|
if (run.role !== "worker" || !run.instruction) continue;
|
|
@@ -147,11 +290,6 @@ export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
|
|
|
147
290
|
return latest;
|
|
148
291
|
}
|
|
149
292
|
|
|
150
|
-
/** Index of the plan step an instruction targets, -1 when nothing matches. */
|
|
151
|
-
export function currentStepIndex(steps: readonly string[], instruction: string | undefined): number {
|
|
152
|
-
return instruction ? steps.findIndex((step) => matchesInstruction(step, instruction)) : -1;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
293
|
function stepStatus(index: number, current: number): PlanStepStatus {
|
|
156
294
|
if (current < 0) return index === 0 ? "current" : "pending";
|
|
157
295
|
if (index < current) return "done";
|
|
@@ -168,37 +306,58 @@ function markThrough(completed: Set<number>, index: number): void {
|
|
|
168
306
|
for (let step = 0; step <= index; step += 1) completed.add(step);
|
|
169
307
|
}
|
|
170
308
|
|
|
309
|
+
function byStart(runs: readonly AgentRun[]): AgentRun[] {
|
|
310
|
+
const time = (run: AgentRun) => {
|
|
311
|
+
const at = Date.parse(run.startedAt);
|
|
312
|
+
return Number.isFinite(at) ? at : 0;
|
|
313
|
+
};
|
|
314
|
+
return runs
|
|
315
|
+
.map((run, order) => ({ run, order }))
|
|
316
|
+
.sort((a, b) => time(a.run) - time(b.run) || a.order - b.order)
|
|
317
|
+
.map((entry) => entry.run);
|
|
318
|
+
}
|
|
319
|
+
|
|
171
320
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* and a failure can never tick one off.
|
|
321
|
+
* Replays worker runs in start order. A successful run completes everything up
|
|
322
|
+
* to its target step; a run that matches nothing -- the common case for a
|
|
323
|
+
* reworded instruction -- completes the next still-open step, so the count only
|
|
324
|
+
* grows and a failure can never tick one off. The latest worker run's own target
|
|
325
|
+
* is reported separately so a running or failed step reads current.
|
|
176
326
|
*/
|
|
177
|
-
function
|
|
327
|
+
function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number; latest: number } {
|
|
178
328
|
const completed = new Set<number>();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
329
|
+
const latestRun = latestWorkerRun(runs);
|
|
330
|
+
let latest = -1;
|
|
331
|
+
for (const run of byStart(runs)) {
|
|
332
|
+
if (run.role !== "worker") continue;
|
|
333
|
+
const matched = targetStep(steps, run.instruction, completed);
|
|
334
|
+
if (run === latestRun) latest = matched >= 0 && run.status === "success" ? matched + 1 : matched;
|
|
335
|
+
if (run.status !== "success") continue;
|
|
182
336
|
const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
|
|
183
337
|
if (target >= 0) markThrough(completed, target);
|
|
184
338
|
}
|
|
185
|
-
return completed.size;
|
|
339
|
+
return { completed: completed.size, latest };
|
|
186
340
|
}
|
|
187
341
|
|
|
342
|
+
/** One-entry memo: the panel asks for the same checklist several times per frame. */
|
|
343
|
+
let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
|
|
344
|
+
|
|
188
345
|
/**
|
|
189
|
-
* Done/current/pending per plan step
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
346
|
+
* Done/current/pending per plan step. A succeeded run completes its step, so the
|
|
347
|
+
* next step becomes current (and the last step reads done); running, failed,
|
|
348
|
+
* cancelled and timeout runs keep it current. Progress is monotonic: steps
|
|
349
|
+
* already completed by successful worker runs stay done, and a later call that
|
|
350
|
+
* names an earlier step can never tick it back. Memoized on the exact plan text
|
|
351
|
+
* and runs array, so callers must not mutate either.
|
|
194
352
|
*/
|
|
195
|
-
export function planChecklist(plan: string, runs: AgentRun[]): PlanStep[] {
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
353
|
+
export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
|
|
354
|
+
if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
|
|
355
|
+
const texts = planSteps(plan);
|
|
356
|
+
const replay = replaySteps(texts, runs);
|
|
357
|
+
const current = Math.max(replay.completed, replay.latest);
|
|
358
|
+
const steps = texts.map((text, index) => ({ text, status: stepStatus(index, current) }));
|
|
359
|
+
checklistMemo = { plan, runs, steps };
|
|
360
|
+
return steps;
|
|
202
361
|
}
|
|
203
362
|
|
|
204
363
|
/** Up to `count` consecutive step indexes centered on the current step, hard-capped at `max`. */
|
|
@@ -211,7 +370,7 @@ export function checklistWindow(steps: readonly PlanStep[], count: number, max =
|
|
|
211
370
|
return Array.from({ length: size }, (_value, offset) => start + offset);
|
|
212
371
|
}
|
|
213
372
|
|
|
214
|
-
function
|
|
373
|
+
function checklistRow(step: PlanStep, ordinal: number): string {
|
|
215
374
|
const icon = step.status === "done" ? "✓" : step.status === "current" ? "◐" : "○";
|
|
216
375
|
return ` ${icon} ${ordinal}. ${step.text}`;
|
|
217
376
|
}
|
|
@@ -329,7 +488,7 @@ function tailLines(task: Task, runs: AgentRun[], now: number, tick: number, step
|
|
|
329
488
|
|
|
330
489
|
function checklistLines(steps: PlanStep[], count: number): string[] {
|
|
331
490
|
if (steps.length === 0 || count <= 0) return [];
|
|
332
|
-
return checklistWindow(steps, count).map((index) =>
|
|
491
|
+
return checklistWindow(steps, count).map((index) => checklistRow(steps[index]!, index + 1));
|
|
333
492
|
}
|
|
334
493
|
|
|
335
494
|
function compactPanel(
|
|
@@ -415,6 +574,7 @@ function sceneInput(
|
|
|
415
574
|
tasks: sceneTasks(steps),
|
|
416
575
|
oracle: oracleSlot(task, expressions),
|
|
417
576
|
oracleActivity,
|
|
577
|
+
caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
|
|
418
578
|
alert: alert?.text,
|
|
419
579
|
alertKind: alert?.kind,
|
|
420
580
|
};
|
|
@@ -429,7 +589,7 @@ export interface PanelOptions {
|
|
|
429
589
|
theme?: PanelTheme;
|
|
430
590
|
/** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
|
|
431
591
|
expressions?: ExpressionFrames;
|
|
432
|
-
/** Live master activity word for the oracle
|
|
592
|
+
/** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
|
|
433
593
|
oracleActivity?: string;
|
|
434
594
|
}
|
|
435
595
|
|
package/src/schemas/task.ts
CHANGED
|
@@ -54,6 +54,22 @@ export interface ReviewRecord {
|
|
|
54
54
|
requiredChanges: string[];
|
|
55
55
|
createdAt: string;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* One finished worker delegation, kept on the task so the plan checklist can be
|
|
59
|
+
* replayed after a reload instead of resetting to the first step.
|
|
60
|
+
*/
|
|
61
|
+
export interface WorkerRunRecord {
|
|
62
|
+
runId: string;
|
|
63
|
+
domain: Domain;
|
|
64
|
+
instruction: string;
|
|
65
|
+
status: "running" | "success" | "failed" | "cancelled" | "timeout";
|
|
66
|
+
startedAt: string;
|
|
67
|
+
finishedAt?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Upper bound on persisted worker records; plans cap at 50 steps. */
|
|
71
|
+
export const MAX_WORKER_RECORDS = 64;
|
|
72
|
+
|
|
57
73
|
export interface Task {
|
|
58
74
|
id: string;
|
|
59
75
|
title: string;
|
|
@@ -71,6 +87,8 @@ export interface Task {
|
|
|
71
87
|
blockers: Blocker[];
|
|
72
88
|
decisions: Decision[];
|
|
73
89
|
approvals: Approval[];
|
|
90
|
+
/** Worker delegations in start order; absent on tasks created before tracking. */
|
|
91
|
+
workerRuns?: WorkerRunRecord[];
|
|
74
92
|
createdAt: string;
|
|
75
93
|
updatedAt: string;
|
|
76
94
|
/** The pi session (ctx.sessionManager id) that owns this task; absent on legacy tasks. */
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { BotLobbyConfig } from "../schemas/configuration.ts";
|
|
3
3
|
import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
MAX_WORKER_RECORDS,
|
|
6
|
+
TASK_STATES,
|
|
7
|
+
TERMINAL_STATES,
|
|
8
|
+
taskRequest,
|
|
9
|
+
type Approval,
|
|
10
|
+
type ApprovalKind,
|
|
11
|
+
type Task,
|
|
12
|
+
type TaskState,
|
|
13
|
+
type WorkerRunRecord,
|
|
14
|
+
} from "../schemas/task.ts";
|
|
5
15
|
import { isDomain, type Domain } from "../schemas/agent.ts";
|
|
6
16
|
import { transition } from "../state/task-state.ts";
|
|
7
17
|
import { ownerlessTask, readTaskArtifact, removeTaskScratchpads, saveTask, selectTask, taskDirFor, taskReadDirs } from "../state/persistence.ts";
|
|
@@ -495,6 +505,19 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
|
|
|
495
505
|
};
|
|
496
506
|
}
|
|
497
507
|
|
|
508
|
+
/** Remember a worker delegation so the checklist replays it after a reload. */
|
|
509
|
+
function recordWorkerRun(task: Task, run: AgentRun): void {
|
|
510
|
+
const record: WorkerRunRecord = {
|
|
511
|
+
runId: run.runId,
|
|
512
|
+
domain: run.domain,
|
|
513
|
+
instruction: run.instruction ?? "",
|
|
514
|
+
status: run.status,
|
|
515
|
+
startedAt: run.startedAt,
|
|
516
|
+
...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
|
|
517
|
+
};
|
|
518
|
+
task.workerRuns = [...(task.workerRuns ?? []), record].slice(-MAX_WORKER_RECORDS);
|
|
519
|
+
}
|
|
520
|
+
|
|
498
521
|
async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
499
522
|
requireState(task, ["planning", "implementing", "reviewing"]);
|
|
500
523
|
const domain = parseDomain(params.domain, "implement");
|
|
@@ -504,6 +527,7 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
504
527
|
if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
505
528
|
if (task.state !== "implementing") transition(task, "implementing");
|
|
506
529
|
const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
|
|
530
|
+
recordWorkerRun(task, outcome.run);
|
|
507
531
|
const approvals = recordWorkerApprovals(task, outcome, deps.config);
|
|
508
532
|
const pushback = recordPushback(task, outcome);
|
|
509
533
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|