frappe-builder 1.1.0-dev.12 → 1.1.0-dev.16

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.
@@ -0,0 +1,248 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { appendFileSync } from "node:fs";
6
+ import { db } from "../state/db.js";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const AGENTS_DIR = resolve(__dirname, "../agents");
10
+ const CHAIN_EVENTS_PATH = join(".fb", "chain_events.jsonl");
11
+ const STEP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per step
12
+ const ARTIFACT_MIN_BYTES = 100;
13
+ const PREV_ARTIFACT_MAX_CHARS = 4096;
14
+
15
+ interface ChainStep {
16
+ phase: string;
17
+ specialist: string;
18
+ artifactFile: string; // relative to artifactDir
19
+ tools: string[];
20
+ }
21
+
22
+ export const CHAIN_STEPS: ChainStep[] = [
23
+ {
24
+ phase: "requirements",
25
+ specialist: "frappe-ba",
26
+ artifactFile: "planning-artifacts/requirements.md",
27
+ tools: ["Read", "Write", "get_frappe_docs"],
28
+ },
29
+ {
30
+ phase: "architecture",
31
+ specialist: "frappe-architect",
32
+ artifactFile: "planning-artifacts/architecture.md",
33
+ tools: ["Read", "Write", "get_frappe_docs"],
34
+ },
35
+ {
36
+ phase: "planning",
37
+ specialist: "frappe-planner",
38
+ artifactFile: "planning-artifacts/plan.md",
39
+ tools: ["Read", "Write", "create_component"],
40
+ },
41
+ {
42
+ phase: "implementation",
43
+ specialist: "frappe-dev",
44
+ artifactFile: "implementation-artifacts/sprint-status.yaml",
45
+ tools: ["Read", "Write", "Edit", "Bash", "create_component", "complete_component", "bench_execute"],
46
+ },
47
+ {
48
+ phase: "testing",
49
+ specialist: "frappe-qa",
50
+ artifactFile: "implementation-artifacts/test-report.md",
51
+ tools: ["Read", "Write", "Bash", "bench_execute"],
52
+ },
53
+ {
54
+ phase: "documentation",
55
+ specialist: "frappe-docs",
56
+ artifactFile: "implementation-artifacts/docs.md",
57
+ tools: ["Read", "Write"],
58
+ },
59
+ ];
60
+
61
+ const PHASE_TASKS: Record<string, string> = {
62
+ requirements:
63
+ "Analyse the feature description and produce requirements.md covering DocTypes, roles, process flows, and acceptance criteria.",
64
+ architecture:
65
+ "Design the Frappe-native technical solution based on the requirements above. Produce architecture.md covering DocType schemas, relationships, server logic, and permissions.",
66
+ planning:
67
+ "Break the architecture into ordered implementation components. Produce plan.md, then call create_component for every component listed.",
68
+ implementation:
69
+ "Implement every component from the plan in order. Call complete_component after each one passes tests.",
70
+ testing:
71
+ "Run the full test suite, verify all acceptance criteria, check the permission matrix, and produce test-report.md with Result: PASS.",
72
+ documentation:
73
+ "Document the implemented feature — DocType fields, hooks, docstrings, and changelog entry — and produce docs.md.",
74
+ };
75
+
76
+ function appendChainEvent(entry: Record<string, unknown>): void {
77
+ try {
78
+ mkdirSync(".fb", { recursive: true });
79
+ appendFileSync(CHAIN_EVENTS_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n", "utf-8");
80
+ } catch { /* non-fatal */ }
81
+ }
82
+
83
+ function readArtifact(artifactDir: string, artifactFile: string): string | null {
84
+ try {
85
+ const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
86
+ return content.slice(0, PREV_ARTIFACT_MAX_CHARS);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function verifyArtifact(artifactDir: string, artifactFile: string): boolean {
93
+ try {
94
+ const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
95
+ return content.length >= ARTIFACT_MIN_BYTES;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ function buildTaskPrompt(
102
+ step: ChainStep,
103
+ featureId: string,
104
+ featureName: string,
105
+ artifactDir: string,
106
+ prevArtifact: string | null,
107
+ ): string {
108
+ const prevSection = prevArtifact
109
+ ? `## Previous Phase Output\n\`\`\`\n${prevArtifact}\n\`\`\`\n\n`
110
+ : "";
111
+
112
+ return [
113
+ `Feature: "${featureName}" (ID: ${featureId})`,
114
+ `Artifact directory: ${artifactDir}`,
115
+ "",
116
+ prevSection,
117
+ `## Your Task`,
118
+ PHASE_TASKS[step.phase],
119
+ "",
120
+ `Write your output to: ${join(artifactDir, step.artifactFile)}`,
121
+ `Do not stop until the artifact file exists with substantive content.`,
122
+ ].join("\n");
123
+ }
124
+
125
+ function runSubprocess(
126
+ step: ChainStep,
127
+ taskPrompt: string,
128
+ ): Promise<{ exitCode: number; stderr: string }> {
129
+ return new Promise((resolvePromise) => {
130
+ const agentFile = join(AGENTS_DIR, `${step.specialist}.md`);
131
+ let systemPrompt = "";
132
+ try {
133
+ systemPrompt = readFileSync(agentFile, "utf-8");
134
+ } catch {
135
+ systemPrompt = `You are ${step.specialist}, a Frappe specialist. ${PHASE_TASKS[step.phase]}`;
136
+ }
137
+
138
+ const args = [
139
+ "--mode", "json",
140
+ "-p",
141
+ "--no-extensions",
142
+ "--tools", step.tools.join(","),
143
+ "--append-system-prompt", systemPrompt,
144
+ taskPrompt,
145
+ ];
146
+
147
+ const proc = spawn("pi", args, {
148
+ stdio: ["ignore", "pipe", "pipe"],
149
+ env: { ...process.env },
150
+ });
151
+
152
+ const stderrChunks: string[] = [];
153
+ proc.stderr?.setEncoding("utf-8");
154
+ proc.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
155
+
156
+ // Hard timeout per step
157
+ const timer = setTimeout(() => proc.kill("SIGTERM"), STEP_TIMEOUT_MS);
158
+
159
+ proc.on("close", (code) => {
160
+ clearTimeout(timer);
161
+ resolvePromise({
162
+ exitCode: code ?? 1,
163
+ stderr: stderrChunks.slice(-20).join("").slice(-2000),
164
+ });
165
+ });
166
+ });
167
+ }
168
+
169
+ function updateDb(featureId: string, phase: string, chainStep: string | null): void {
170
+ try {
171
+ db.transaction(() => {
172
+ db.prepare("UPDATE sessions SET chain_step = ? WHERE is_active = 1").run(chainStep);
173
+ db.prepare("UPDATE features SET current_phase = ? WHERE feature_id = ?").run(phase, featureId);
174
+ })();
175
+ } catch { /* non-fatal — chain continues */ }
176
+ }
177
+
178
+ /**
179
+ * Runs the full agent chain for a feature. Fire-and-forget: called via setImmediate from startFeature.
180
+ * Each phase spawns an isolated pi subprocess with a specialist system prompt.
181
+ * Artifacts are passed between phases via files in artifactDir.
182
+ */
183
+ export async function spawnChain(
184
+ featureId: string,
185
+ featureName: string,
186
+ artifactDir: string,
187
+ ): Promise<void> {
188
+ appendChainEvent({ featureId, featureName, status: "chain_started" });
189
+
190
+ let prevArtifact: string | null = null;
191
+
192
+ for (const step of CHAIN_STEPS) {
193
+ // Update state: chain is now on this phase
194
+ updateDb(featureId, step.phase, step.phase);
195
+ appendChainEvent({ featureId, phase: step.phase, status: "started" });
196
+
197
+ // Ensure artifact subdirs exist
198
+ const planningDir = join(artifactDir, "planning-artifacts");
199
+ const implDir = join(artifactDir, "implementation-artifacts");
200
+ mkdirSync(planningDir, { recursive: true });
201
+ mkdirSync(implDir, { recursive: true });
202
+
203
+ const taskPrompt = buildTaskPrompt(step, featureId, featureName, artifactDir, prevArtifact);
204
+
205
+ // Run subprocess — retry once if artifact missing despite exit 0
206
+ let result = await runSubprocess(step, taskPrompt);
207
+ const artifactOk = verifyArtifact(artifactDir, step.artifactFile);
208
+
209
+ if (result.exitCode === 0 && !artifactOk) {
210
+ // Retry with explicit nudge
211
+ const retryPrompt = taskPrompt +
212
+ `\n\nWARNING: Your artifact was not detected at ${join(artifactDir, step.artifactFile)}. ` +
213
+ `You MUST write this file before exiting.`;
214
+ result = await runSubprocess(step, retryPrompt);
215
+ }
216
+
217
+ if (result.exitCode !== 0 || !verifyArtifact(artifactDir, step.artifactFile)) {
218
+ // Chain failed — write error file and update state
219
+ const errorContent = [
220
+ `# Chain Error`,
221
+ `Phase: ${step.phase}`,
222
+ `Exit code: ${result.exitCode}`,
223
+ `Artifact expected: ${join(artifactDir, step.artifactFile)}`,
224
+ `Artifact found: ${verifyArtifact(artifactDir, step.artifactFile)}`,
225
+ ``,
226
+ `## Stderr (last 2000 chars)`,
227
+ result.stderr,
228
+ ].join("\n");
229
+
230
+ try {
231
+ writeFileSync(join(artifactDir, "chain_error.md"), errorContent, "utf-8");
232
+ } catch { /* non-fatal */ }
233
+
234
+ updateDb(featureId, "chain_failed", null);
235
+ appendChainEvent({ featureId, phase: step.phase, status: "failed", exitCode: result.exitCode });
236
+ return;
237
+ }
238
+
239
+ appendChainEvent({ featureId, phase: step.phase, status: "complete" });
240
+
241
+ // Pass this phase's artifact to the next phase
242
+ prevArtifact = readArtifact(artifactDir, step.artifactFile);
243
+ }
244
+
245
+ // All steps complete
246
+ updateDb(featureId, "done", null);
247
+ appendChainEvent({ featureId, status: "chain_complete" });
248
+ }
@@ -290,6 +290,11 @@ export async function handleSessionStart(): Promise<string> {
290
290
 
291
291
  // Specialist auto-load on session resume (Story 3.4)
292
292
  // AGENTS.md base identity (Nexus) is loaded natively by pi from project root.
293
+ if (ctx.phase === "chain_running") {
294
+ // Agent chain is running as a background subprocess — no specialist injection.
295
+ // Use get_project_status to monitor chain progress.
296
+ return `${stateBlock}\n\nAgent chain is running. Use get_project_status to monitor progress.`;
297
+ }
293
298
  if (ctx.phase !== "idle") {
294
299
  try {
295
300
  const specCtx = buildStateContext(null, "session_start");
@@ -8,6 +8,28 @@ import { loadCredentials } from "../config/loader.js";
8
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
9
  export default function (pi: any) {
10
10
  pi.on("after_tool_call", handleAfterToolCall);
11
+
12
+ // Session-end guard: warn the agent about untracked features before shutdown.
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ pi.on("session_shutdown", (_event: any, ctx: any) => {
15
+ try {
16
+ const orphans = db
17
+ .prepare(
18
+ `SELECT feature_id, name, mode FROM features
19
+ WHERE current_phase != 'idle' AND progress_total = 0`
20
+ )
21
+ .all() as Array<{ feature_id: string; name: string; mode: string }>;
22
+
23
+ if (orphans.length > 0) {
24
+ const list = orphans.map((f) => ` • ${f.name} [${f.feature_id}] (${f.mode})`).join("\n");
25
+ const warning = `\n⚠ Session ending with ${orphans.length} feature(s) that have no components tracked:\n${list}\n\nCall create_component() + complete_component() before the next session to preserve work history.`;
26
+ try {
27
+ ctx.ui.setStatus("frappe-builder", `⚠ ${orphans.length} untracked feature(s) — run create_component`);
28
+ } catch { /* non-fatal */ }
29
+ console.error(warning);
30
+ }
31
+ } catch { /* non-fatal — never block shutdown */ }
32
+ });
11
33
  }
12
34
 
13
35
  /** Reads current_phase and feature_id from the active session. */
@@ -29,6 +51,29 @@ function getActiveSessionId(): string {
29
51
  return row?.session_id ?? "unknown";
30
52
  }
31
53
 
54
+ /** Type guard for createComponent() return value. */
55
+ function isCreateComponentResult(value: unknown): value is { feature_id: string; component_id: string; artifact_dir: string } {
56
+ return (
57
+ typeof value === "object" &&
58
+ value !== null &&
59
+ "feature_id" in value &&
60
+ "component_id" in value &&
61
+ "artifact_dir" in value
62
+ );
63
+ }
64
+
65
+ /** Type guard for completeComponent() success return value. */
66
+ function isCompleteComponentResult(value: unknown): value is { feature_id: string; component_id: string; status: string } {
67
+ return (
68
+ typeof value === "object" &&
69
+ value !== null &&
70
+ "feature_id" in value &&
71
+ "component_id" in value &&
72
+ "status" in value &&
73
+ (value as Record<string, unknown>).status === "complete"
74
+ );
75
+ }
76
+
32
77
  /** Type guard for SpawnAgentResult from agent-tools.ts. */
33
78
  function isSpawnAgentResult(value: unknown): value is { status: string; skill: string; trigger: string } {
34
79
  return (
@@ -41,14 +86,16 @@ function isSpawnAgentResult(value: unknown): value is { status: string; skill: s
41
86
  }
42
87
 
43
88
  /** Type guard for the return value of startFeature(). */
44
- function isStartFeatureResult(value: unknown): value is { mode: string; feature_id: string } {
89
+ function isStartFeatureResult(value: unknown): value is { mode: string; feature_id: string; current_phase: string } {
45
90
  return (
46
91
  typeof value === "object" &&
47
92
  value !== null &&
48
93
  "mode" in value &&
49
94
  "feature_id" in value &&
95
+ "current_phase" in value &&
50
96
  typeof (value as Record<string, unknown>).mode === "string" &&
51
- typeof (value as Record<string, unknown>).feature_id === "string"
97
+ typeof (value as Record<string, unknown>).feature_id === "string" &&
98
+ typeof (value as Record<string, unknown>).current_phase === "string"
52
99
  );
53
100
  }
54
101
 
@@ -131,13 +178,21 @@ export async function handleAfterToolCall(
131
178
  // non-JSON result — skip phase transition silently
132
179
  }
133
180
  if (isStartFeatureResult(parsed)) {
134
- const newPhase = parsed.mode === "quick" ? "implementation" : "requirements";
135
- applyPhaseTransition(parsed.feature_id, newPhase);
181
+ // Full mode: chain running as background subprocess — inject status banner
182
+ if (parsed.current_phase === "chain_running") {
183
+ const banner: TextContent = {
184
+ type: "text",
185
+ text: `\n\nAgent chain started for "${parsed.feature_id}" (full mode).\n` +
186
+ `Each phase runs as an isolated subprocess. Monitor progress with get_project_status.\n` +
187
+ `The chain will advance through: requirements → architecture → planning → implementation → testing → documentation.`,
188
+ };
189
+ return { content: [...context.result.content, banner] };
190
+ }
136
191
 
137
- // Specialist auto-load after phase transition (Story 3.4) — non-fatal
192
+ // Quick mode: specialist auto-load after phase transition (Story 3.4) — non-fatal
138
193
  try {
139
194
  const specCtx = buildStateContext(parsed.feature_id, toolName);
140
- const specialistBlock = loadSpecialist(newPhase, specCtx);
195
+ const specialistBlock = loadSpecialist(parsed.current_phase, specCtx);
141
196
  if (specialistBlock) {
142
197
  const specialistContent: TextContent = {
143
198
  type: "text",
@@ -147,7 +202,6 @@ export async function handleAfterToolCall(
147
202
  }
148
203
  } catch (specialistErr) {
149
204
  console.error(`[frappe-state] specialist load failed: ${specialistErr}`);
150
- // non-fatal — session continues without specialist block
151
205
  }
152
206
  }
153
207
  }
@@ -204,6 +258,51 @@ export async function handleAfterToolCall(
204
258
  }
205
259
  }
206
260
 
261
+ // create_component JSONL logging — no phase transition, no specialist change
262
+ if (toolName === "create_component") {
263
+ const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
264
+ if (firstText) {
265
+ let parsed: unknown = null;
266
+ try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
267
+ if (isCreateComponentResult(parsed)) {
268
+ appendEntry({
269
+ ts: new Date().toISOString(),
270
+ sessionId,
271
+ type: "tool_call",
272
+ payload: { tool: "create_component", feature: parsed.feature_id, component: parsed.component_id },
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ // complete_component — check if all components done and inject completion hint
279
+ if (toolName === "complete_component") {
280
+ const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
281
+ if (firstText) {
282
+ let parsed: unknown = null;
283
+ try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
284
+ if (isCompleteComponentResult(parsed)) {
285
+ appendEntry({
286
+ ts: new Date().toISOString(),
287
+ sessionId,
288
+ type: "tool_call",
289
+ payload: { tool: "complete_component", feature: parsed.feature_id, component: parsed.component_id },
290
+ });
291
+ // Check if all components for this feature are now complete
292
+ const feature = db
293
+ .prepare("SELECT progress_done, progress_total FROM features WHERE feature_id = ?")
294
+ .get(parsed.feature_id) as { progress_done: number; progress_total: number } | undefined;
295
+ if (feature && feature.progress_total > 0 && feature.progress_done >= feature.progress_total) {
296
+ const hint: TextContent = {
297
+ type: "text",
298
+ text: `\n\n✓ All ${feature.progress_total} components complete for this feature. Ready to advance to the next phase.`,
299
+ };
300
+ return { content: [...context.result.content, hint] };
301
+ }
302
+ }
303
+ }
304
+ }
305
+
207
306
  // End debug — restore previous phase specialist (Story 3.5) — no phase transition
208
307
  if (toolName === "end_debug") {
209
308
  const { currentPhase, activeFeatureId } = readSessionState();
@@ -1,72 +1,126 @@
1
1
  import type { AfterToolCallContext, AfterToolCallResult } from "@mariozechner/pi-agent-core";
2
2
  import { db, getCurrentPhase } from "../state/db.js";
3
+ import { loadConfig } from "../config/loader.js";
4
+ import { DEFAULT_PERMISSION_MODE } from "../config/defaults.js";
3
5
 
4
- interface FooterState {
6
+ interface DashboardState {
5
7
  projectId: string | null;
6
8
  featureName: string | null;
9
+ featureMode: string | null;
10
+ componentId: string | null;
7
11
  phase: string;
8
- tokenCount: number;
12
+ chainStep: string | null;
13
+ progressDone: number;
14
+ progressTotal: number;
15
+ permissionMode: string;
9
16
  }
10
17
 
11
- function readFooterState(): FooterState {
18
+ // Keep FooterState alias so existing tests compile without changes.
19
+ type FooterState = Pick<DashboardState, "projectId" | "featureName" | "componentId">;
20
+
21
+ function readDashboardState(): DashboardState {
12
22
  try {
13
23
  const session = db
14
24
  .prepare(
15
- `SELECT s.project_id, f.name AS feature_name, s.current_phase
25
+ `SELECT s.project_id, s.current_phase, s.component_id, s.chain_step,
26
+ f.name AS feature_name, f.mode AS feature_mode,
27
+ f.progress_done, f.progress_total
16
28
  FROM sessions s
17
29
  LEFT JOIN features f ON f.feature_id = s.feature_id
18
30
  WHERE s.is_active = 1 LIMIT 1`
19
31
  )
20
32
  .get() as
21
- | { project_id: string | null; feature_name: string | null; current_phase: string }
33
+ | {
34
+ project_id: string | null;
35
+ current_phase: string;
36
+ component_id: string | null;
37
+ chain_step: string | null;
38
+ feature_name: string | null;
39
+ feature_mode: string | null;
40
+ progress_done: number | null;
41
+ progress_total: number | null;
42
+ }
22
43
  | undefined;
23
44
 
24
- // Token count: count journal_entries for active session; table may not exist yet
25
- let tokenCount = 0;
26
- try {
27
- const tokenRow = db
28
- .prepare(
29
- `SELECT COUNT(*) as count FROM journal_entries
30
- WHERE session_id = (SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1)`
31
- )
32
- .get() as { count: number } | undefined;
33
- tokenCount = tokenRow?.count ?? 0;
34
- } catch {
35
- // journal_entries table not yet created — default to 0
36
- }
45
+ const permissionMode = (() => {
46
+ try { return loadConfig().permissionMode ?? DEFAULT_PERMISSION_MODE; } catch { return DEFAULT_PERMISSION_MODE; }
47
+ })();
37
48
 
38
49
  return {
39
50
  projectId: session?.project_id ?? null,
40
51
  featureName: session?.feature_name ?? null,
52
+ featureMode: session?.feature_mode ?? null,
53
+ componentId: session?.component_id ?? null,
41
54
  phase: session?.current_phase ?? "idle",
42
- tokenCount,
55
+ chainStep: session?.chain_step ?? null,
56
+ progressDone: session?.progress_done ?? 0,
57
+ progressTotal: session?.progress_total ?? 0,
58
+ permissionMode,
43
59
  };
44
60
  } catch {
45
- return { projectId: null, featureName: null, phase: "idle", tokenCount: 0 };
61
+ return { projectId: null, featureName: null, featureMode: null, componentId: null, phase: "idle", chainStep: null, progressDone: 0, progressTotal: 0, permissionMode: DEFAULT_PERMISSION_MODE };
46
62
  }
47
63
  }
48
64
 
49
65
  /**
50
- * Pure formatting function no I/O, no side effects.
66
+ * Formats dashboard state as a string[] for ctx.ui.setWidget().
51
67
  * Exported for unit testing.
52
68
  */
53
- export function formatFooter(state: FooterState): string {
69
+ export function formatDashboard(state: DashboardState): string[] {
54
70
  if (!state.projectId) {
55
- return `No active project | Phase: ${state.phase}`;
71
+ return ["frappe-builder | No active project"];
56
72
  }
57
- if (!state.featureName) {
58
- return `Project: ${state.projectId} | No active feature | Phase: ${state.phase}`;
73
+
74
+ const parts: string[] = [`Project: ${state.projectId}`];
75
+
76
+ if (state.featureName) {
77
+ const modeTag = state.featureMode ? ` [${state.featureMode}]` : "";
78
+ parts.push(`Feature: ${state.featureName}${modeTag}`);
79
+ } else {
80
+ parts.push("No active feature");
81
+ }
82
+
83
+ if (state.componentId) {
84
+ parts.push(`Component: ${state.componentId}`);
85
+ }
86
+
87
+ if (state.phase === "chain_running") {
88
+ parts.push(`Phase: chain:${state.chainStep ?? "starting"}`);
89
+ } else {
90
+ parts.push(`Phase: ${state.phase}`);
91
+ }
92
+
93
+ if (state.progressTotal > 0) {
94
+ parts.push(`${state.progressDone}/${state.progressTotal} components`);
59
95
  }
60
- return `Project: ${state.projectId} | Feature: ${state.featureName} | Phase: ${state.phase} | Tokens: ${state.tokenCount}`;
96
+
97
+ parts.push(`Mode: ${state.permissionMode}`);
98
+
99
+ return [`frappe-builder | ${parts.join(" | ")}`];
100
+ }
101
+
102
+ /**
103
+ * Legacy single-line footer format — kept for backward-compat with existing tests.
104
+ * Exported for unit testing.
105
+ */
106
+ export function formatFooter(state: FooterState): string {
107
+ if (!state.projectId) return "No active project";
108
+ if (!state.featureName) return `Project: ${state.projectId} | No active feature`;
109
+ if (!state.componentId) return `Project: ${state.projectId} | Feature: ${state.featureName}`;
110
+ return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
61
111
  }
62
112
 
63
- function renderFooter(): void {
113
+ function renderWidget(ctx: unknown): void {
64
114
  try {
65
- const state = readFooterState();
66
- const line = formatFooter(state);
67
- process.stderr.write(`\r\x1b[2K[frappe-builder] ${line}\n`);
115
+ const lines = formatDashboard(readDashboardState());
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ (ctx as any).ui.setWidget("frappe-builder", lines, { placement: "aboveEditor" });
68
118
  } catch {
69
- // Silently swallow all rendering errors footer is non-critical
119
+ // Fallback: write to stderr if widget API unavailable
120
+ try {
121
+ const state = readDashboardState();
122
+ process.stderr.write(`\r\x1b[2K[frappe-builder] ${formatDashboard(state).join(" ")}\n`);
123
+ } catch { /* non-critical */ }
70
124
  }
71
125
  }
72
126
 
@@ -98,22 +152,33 @@ export async function handleAfterToolCall(
98
152
  _context: AfterToolCallContext,
99
153
  _signal?: AbortSignal
100
154
  ): Promise<AfterToolCallResult | undefined> {
101
- renderFooter();
102
155
  return undefined;
103
156
  }
104
157
 
105
158
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
159
  export default function (pi: any) {
107
- pi.on("session_start", () => {
108
- renderFooter();
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ pi.on("session_start", (_event: any, ctx: any) => {
162
+ renderWidget(ctx);
109
163
  });
110
164
 
111
165
  // Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
112
- pi.on("tool_call", (event: { toolName?: string }) => {
113
- return handleBeforeToolCall(event.toolName ?? "");
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
167
+ pi.on("tool_call", (event: any, ctx: any) => {
168
+ try {
169
+ const state = readDashboardState();
170
+ const lines = formatDashboard(state);
171
+ const callLine = `→ ${event.toolName ?? "tool"} [${state.phase}]`;
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
+ (ctx as any).ui.setWidget("frappe-builder", [...lines, callLine], { placement: "aboveEditor" });
174
+ } catch {
175
+ handleBeforeToolCall(event.toolName ?? "");
176
+ }
114
177
  });
115
178
 
116
- pi.on("after_tool_call", async (context: AfterToolCallContext) => {
117
- return await handleAfterToolCall(context);
179
+ // "tool_result" fires after each tool completes — update the persistent widget.
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ pi.on("tool_result", (_event: any, ctx: any) => {
182
+ renderWidget(ctx);
118
183
  });
119
184
  }