@ilya-lesikov/pi-pi 0.7.0 → 0.9.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.
Files changed (75) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
  15. package/extensions/orchestrator/agents/constraints.test.ts +82 -0
  16. package/extensions/orchestrator/agents/constraints.ts +66 -2
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +39 -1
  24. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  25. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  26. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  27. package/extensions/orchestrator/command-handlers.ts +44 -28
  28. package/extensions/orchestrator/config.test.ts +40 -1
  29. package/extensions/orchestrator/config.ts +18 -2
  30. package/extensions/orchestrator/context.test.ts +54 -0
  31. package/extensions/orchestrator/context.ts +81 -2
  32. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  33. package/extensions/orchestrator/custom-footer.ts +20 -20
  34. package/extensions/orchestrator/event-handlers.test.ts +412 -2
  35. package/extensions/orchestrator/event-handlers.ts +556 -227
  36. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  37. package/extensions/orchestrator/flant-infra.ts +91 -0
  38. package/extensions/orchestrator/index.ts +1 -1
  39. package/extensions/orchestrator/integration.test.ts +411 -20
  40. package/extensions/orchestrator/messages.test.ts +30 -0
  41. package/extensions/orchestrator/messages.ts +6 -0
  42. package/extensions/orchestrator/model-registry.test.ts +48 -1
  43. package/extensions/orchestrator/model-registry.ts +43 -1
  44. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  46. package/extensions/orchestrator/orchestrator.ts +197 -60
  47. package/extensions/orchestrator/phases/brainstorm.ts +20 -27
  48. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  49. package/extensions/orchestrator/phases/implementation.ts +4 -6
  50. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  51. package/extensions/orchestrator/phases/machine.ts +11 -1
  52. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  53. package/extensions/orchestrator/phases/planning.ts +11 -4
  54. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  55. package/extensions/orchestrator/phases/review-task.ts +47 -22
  56. package/extensions/orchestrator/phases/review.test.ts +34 -0
  57. package/extensions/orchestrator/phases/review.ts +44 -6
  58. package/extensions/orchestrator/plannotator.ts +9 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  60. package/extensions/orchestrator/pp-menu.ts +514 -402
  61. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  62. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  63. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  64. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  65. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  66. package/extensions/orchestrator/state.test.ts +9 -0
  67. package/extensions/orchestrator/state.ts +15 -0
  68. package/extensions/orchestrator/test-helpers.ts +4 -1
  69. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  70. package/extensions/orchestrator/transition-controller.ts +61 -3
  71. package/extensions/orchestrator/usage-tracker.ts +5 -1
  72. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  73. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  74. package/package.json +1 -1
  75. package/AGENTS.md +0 -28
@@ -122,10 +122,16 @@ describe("validateConfig", () => {
122
122
  );
123
123
  });
124
124
 
125
+ it("throws for invalid injectAgentsMd value", () => {
126
+ expect(() => validateConfig({ general: { injectAgentsMd: "yes" } })).toThrow(
127
+ "config.general.injectAgentsMd",
128
+ );
129
+ });
130
+
125
131
  it("accepts valid partial config", () => {
126
132
  expect(() =>
127
133
  validateConfig({
128
- general: { autoCommit: false },
134
+ general: { autoCommit: false, injectAgentsMd: false },
129
135
  commands: {
130
136
  afterEdit: { fmt: { run: "npm run fmt", globs: ["**/*.ts"] } },
131
137
  afterImplement: { test: { run: "npm test" } },
@@ -192,6 +198,39 @@ describe("loadConfig", () => {
192
198
  expect(config.performance.commands.afterEdit).toBe(1234);
193
199
  expect(config.performance.commands.afterImplement).toBe(300000);
194
200
  expect(config.general.autoCommit).toBe(false);
201
+ expect(config.general.injectAgentsMd).toBe(true);
202
+ });
203
+
204
+ it("defaults injectAgentsMd to true and honors an explicit override", () => {
205
+ const cwd = makeTempDir();
206
+ const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
207
+ expect(defaults.general.injectAgentsMd).toBe(true);
208
+
209
+ const cwd2 = makeTempDir();
210
+ const ppDir = join(cwd2, ".pp");
211
+ mkdirSync(ppDir, { recursive: true });
212
+ writeFileSync(join(ppDir, "config.json"), JSON.stringify({ general: { injectAgentsMd: false } }), "utf-8");
213
+ const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
214
+ expect(overridden.general.injectAgentsMd).toBe(false);
215
+ });
216
+
217
+ it("defaults mainTurnStale to 10m and normalizes an override to ms", () => {
218
+ const cwd = makeTempDir();
219
+ const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
220
+ expect(defaults.performance.internals.mainTurnStale).toBe(600000);
221
+
222
+ const cwd2 = makeTempDir();
223
+ const ppDir = join(cwd2, ".pp");
224
+ mkdirSync(ppDir, { recursive: true });
225
+ writeFileSync(join(ppDir, "config.json"), JSON.stringify({ performance: { internals: { mainTurnStale: "90s" } } }), "utf-8");
226
+ const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
227
+ expect(overridden.performance.internals.mainTurnStale).toBe(90000);
228
+ });
229
+
230
+ it("rejects an invalid mainTurnStale duration", () => {
231
+ expect(() => validateConfig({ performance: { internals: { mainTurnStale: "soon" } } })).toThrow(
232
+ "config.performance.internals.mainTurnStale",
233
+ );
195
234
  });
196
235
 
197
236
  it("creates default config when config.json does not exist", () => {
@@ -7,7 +7,7 @@ import { isValidLogLevel, getLogger, type LogLevel } from "./log.js";
7
7
 
8
8
  export type DurationValue = string | number;
9
9
  export type OrchestratorRole = "implement" | "plan" | "debug" | "brainstorm" | "review" | "quick";
10
- export type SimpleSubagentRole = "explore" | "librarian" | "task";
10
+ export type SimpleSubagentRole = "explore" | "librarian" | "task" | "advisor" | "deep-debugger" | "reviewer";
11
11
  export type PresetGroupKey = "planners" | "codeReviewers" | "planReviewers" | "brainstormReviewers";
12
12
 
13
13
  export interface AgentConfig {
@@ -43,6 +43,7 @@ export interface AfterImplementCommandConfig {
43
43
  export interface PiPiConfig {
44
44
  general: {
45
45
  autoCommit: boolean;
46
+ injectAgentsMd: boolean;
46
47
  loadExtraRepoConfigs: boolean;
47
48
  logLevel: LogLevel;
48
49
  tracing: boolean;
@@ -65,6 +66,7 @@ export interface PiPiConfig {
65
66
  };
66
67
  internals: {
67
68
  subagentStale: DurationValue;
69
+ mainTurnStale: DurationValue;
68
70
  taskLockStale: DurationValue;
69
71
  taskLockRefresh: DurationValue;
70
72
  };
@@ -79,6 +81,7 @@ export interface NormalizedPiPiConfig extends PiPiConfig {
79
81
  };
80
82
  internals: {
81
83
  subagentStale: number;
84
+ mainTurnStale: number;
82
85
  taskLockStale: number;
83
86
  taskLockRefresh: number;
84
87
  };
@@ -92,11 +95,12 @@ export type TimeoutConfig = NormalizedPiPiConfig["performance"]["internals"];
92
95
  export const PRESET_GROUPS = ["planners", "codeReviewers", "planReviewers", "brainstormReviewers"] as const;
93
96
 
94
97
  const ORCHESTRATOR_ROLES: OrchestratorRole[] = ["implement", "plan", "debug", "brainstorm", "review", "quick"];
95
- const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task"];
98
+ const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
96
99
 
97
100
  const DEFAULT_CONFIG: PiPiConfig = {
98
101
  general: {
99
102
  autoCommit: true,
103
+ injectAgentsMd: true,
100
104
  loadExtraRepoConfigs: true,
101
105
  logLevel: "info",
102
106
  tracing: false,
@@ -115,6 +119,9 @@ const DEFAULT_CONFIG: PiPiConfig = {
115
119
  explore: { model: "google/gemini-flash-latest", thinking: "low" },
116
120
  librarian: { model: "google/gemini-flash-latest", thinking: "medium" },
117
121
  task: { model: "anthropic/claude-opus-latest", thinking: "medium" },
122
+ advisor: { model: "openai/gpt-latest", thinking: "high" },
123
+ "deep-debugger": { model: "openai/gpt-latest", thinking: "high" },
124
+ reviewer: { model: "openai/gpt-latest", thinking: "high" },
118
125
  },
119
126
  presetGroups: {
120
127
  planners: {
@@ -200,6 +207,7 @@ const DEFAULT_CONFIG: PiPiConfig = {
200
207
  },
201
208
  internals: {
202
209
  subagentStale: "5m",
210
+ mainTurnStale: "10m",
203
211
  taskLockStale: "1m",
204
212
  taskLockRefresh: "30s",
205
213
  },
@@ -344,6 +352,7 @@ export function validateConfig(config: Record<string, any>): void {
344
352
  if (config.general !== undefined) {
345
353
  const general = requireObject(config.general, "config.general");
346
354
  ensureBool(general.autoCommit, "config.general.autoCommit");
355
+ ensureBool(general.injectAgentsMd, "config.general.injectAgentsMd");
347
356
  ensureBool(general.loadExtraRepoConfigs, "config.general.loadExtraRepoConfigs");
348
357
  ensureBool(general.tracing, "config.general.tracing");
349
358
  if (general.logLevel !== undefined && !isValidLogLevel(general.logLevel)) {
@@ -414,6 +423,7 @@ export function validateConfig(config: Record<string, any>): void {
414
423
  if (performance.internals !== undefined) {
415
424
  const internals = requireObject(performance.internals, "config.performance.internals");
416
425
  ensureDuration(internals.subagentStale, "config.performance.internals.subagentStale");
426
+ ensureDuration(internals.mainTurnStale, "config.performance.internals.mainTurnStale");
417
427
  ensureDuration(internals.taskLockStale, "config.performance.internals.taskLockStale");
418
428
  ensureDuration(internals.taskLockRefresh, "config.performance.internals.taskLockRefresh");
419
429
  }
@@ -523,6 +533,9 @@ export function validateMergedConfig(config: Record<string, any>): void {
523
533
  if (parseDuration(typed.performance.internals.subagentStale) === null) {
524
534
  throw new Error("config.performance.internals.subagentStale must be a valid duration");
525
535
  }
536
+ if (parseDuration(typed.performance.internals.mainTurnStale) === null) {
537
+ throw new Error("config.performance.internals.mainTurnStale must be a valid duration");
538
+ }
526
539
  if (parseDuration(typed.performance.internals.taskLockStale) === null) {
527
540
  throw new Error("config.performance.internals.taskLockStale must be a valid duration");
528
541
  }
@@ -537,6 +550,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
537
550
  const afterEdit = parseDuration(next.performance.commands.afterEdit);
538
551
  const afterImplement = parseDuration(next.performance.commands.afterImplement);
539
552
  const subagentStale = parseDuration(next.performance.internals.subagentStale);
553
+ const mainTurnStale = parseDuration(next.performance.internals.mainTurnStale);
540
554
  const taskLockStale = parseDuration(next.performance.internals.taskLockStale);
541
555
  const taskLockRefresh = parseDuration(next.performance.internals.taskLockRefresh);
542
556
 
@@ -544,6 +558,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
544
558
  afterEdit === null ||
545
559
  afterImplement === null ||
546
560
  subagentStale === null ||
561
+ mainTurnStale === null ||
547
562
  taskLockStale === null ||
548
563
  taskLockRefresh === null
549
564
  ) {
@@ -553,6 +568,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
553
568
  next.performance.commands.afterEdit = afterEdit;
554
569
  next.performance.commands.afterImplement = afterImplement;
555
570
  next.performance.internals.subagentStale = subagentStale;
571
+ next.performance.internals.mainTurnStale = mainTurnStale;
556
572
  next.performance.internals.taskLockStale = taskLockStale;
557
573
  next.performance.internals.taskLockRefresh = taskLockRefresh;
558
574
  return next;
@@ -14,6 +14,8 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
14
14
  import {
15
15
  getContextDirs,
16
16
  getLatestSynthesizedPlan,
17
+ getArtifactManifest,
18
+ formatManifestBlock,
17
19
  getPhaseArtifacts,
18
20
  loadAllContextFiles,
19
21
  loadContextFiles,
@@ -377,6 +379,58 @@ describe("getLatestSynthesizedPlan", () => {
377
379
  });
378
380
  });
379
381
 
382
+ describe("getArtifactManifest", () => {
383
+ it("returns empty when no artifacts or plan exist", () => {
384
+ const taskDir = makeTempDir();
385
+ expect(getArtifactManifest(taskDir)).toEqual([]);
386
+ });
387
+
388
+ it("extracts titles from artifact headings with a filename fallback", () => {
389
+ const taskDir = makeTempDir();
390
+ const artifactsDir = join(taskDir, "artifacts");
391
+ mkdirSync(artifactsDir, { recursive: true });
392
+ writeFileSync(join(artifactsDir, "design.md"), "# My Design\n\nbody", "utf-8");
393
+ writeFileSync(join(artifactsDir, "notitle.md"), "no heading here", "utf-8");
394
+
395
+ const manifest = getArtifactManifest(taskDir);
396
+ expect(manifest).toEqual([
397
+ { title: "My Design", path: join(artifactsDir, "design.md") },
398
+ { title: "artifacts/notitle.md", path: join(artifactsDir, "notitle.md") },
399
+ ]);
400
+ });
401
+
402
+ it("includes the latest synthesized plan with its REAL path, regardless of phase", () => {
403
+ const taskDir = makeTempDir();
404
+ const plansDir = join(taskDir, "plans");
405
+ mkdirSync(plansDir, { recursive: true });
406
+ writeFileSync(join(plansDir, "001_synthesized.md"), "old", "utf-8");
407
+ writeFileSync(join(plansDir, "010_synthesized.md"), "new", "utf-8");
408
+
409
+ const manifest = getArtifactManifest(taskDir);
410
+ expect(manifest).toEqual([
411
+ { title: "Synthesized implementation plan", path: join(plansDir, "010_synthesized.md") },
412
+ ]);
413
+ });
414
+ });
415
+
416
+ describe("formatManifestBlock", () => {
417
+ it("emits the do-not-re-read line only when the manifest is empty", () => {
418
+ const block = formatManifestBlock([]);
419
+ expect(block).toContain("Do NOT re-read them from disk");
420
+ expect(block).not.toContain("read them from disk with the read tool");
421
+ });
422
+
423
+ it("lists each manifest entry's path and title for on-demand reading", () => {
424
+ const block = formatManifestBlock([
425
+ { title: "Design", path: "/t/artifacts/design.md" },
426
+ { title: "Synthesized implementation plan", path: "/t/plans/1_synthesized.md" },
427
+ ]);
428
+ expect(block).toContain("do NOT re-read them from disk");
429
+ expect(block).toContain("- /t/artifacts/design.md — Design");
430
+ expect(block).toContain("- /t/plans/1_synthesized.md — Synthesized implementation plan");
431
+ });
432
+ });
433
+
380
434
  describe("context regressions", () => {
381
435
  it("parses bracket array values without truncation when closing bracket is missing", () => {
382
436
  const cwd = makeTempDir();
@@ -5,7 +5,7 @@ import type { RepoInfo } from "./repo-utils.js";
5
5
  import type { Phase } from "./state.js";
6
6
  import { getLogger } from "./log.js";
7
7
 
8
- type AgentType = "main" | "explore" | "librarian" | "planner" | "planReviewer" | "task" | "codeReviewer" | "brainstormReviewer";
8
+ type AgentType = "main" | "explore" | "librarian" | "planner" | "planReviewer" | "task" | "codeReviewer" | "brainstormReviewer" | "advisor" | "deep-debugger" | "reviewer";
9
9
  type AgentGroup = "all" | "subagents";
10
10
  type InjectMode = "system" | "context";
11
11
  type PhaseFilter = "brainstorm" | "debug" | "plan" | "implement" | "review";
@@ -30,7 +30,7 @@ interface Frontmatter {
30
30
  }
31
31
 
32
32
  const VALID_INJECT_MODES: readonly string[] = ["system", "context"];
33
- const VALID_AGENTS: readonly string[] = ["main", "explore", "librarian", "planner", "planReviewer", "task", "codeReviewer", "brainstormReviewer"];
33
+ const VALID_AGENTS: readonly string[] = ["main", "explore", "librarian", "planner", "planReviewer", "task", "codeReviewer", "brainstormReviewer", "advisor", "deep-debugger", "reviewer"];
34
34
  const VALID_AGENT_GROUPS: readonly string[] = ["all", "subagents"];
35
35
  const VALID_PHASES: readonly string[] = ["brainstorm", "debug", "plan", "implement", "review"];
36
36
  const VALID_VENDORS: readonly string[] = ["anthropic", "openai", "google", "unknown"];
@@ -279,6 +279,85 @@ export function getLatestSynthesizedPlan(taskDir: string): string | null {
279
279
  return readFileSync(join(plansDir, synthFiles[synthFiles.length - 1]), "utf-8");
280
280
  }
281
281
 
282
+ // True when the newest `code-reviews/*_final_pass-*.md` exists and carries an
283
+ // `ANCHORS:` block. Shared by publishGuard (Publish menu) and the review-phase
284
+ // completion gates so a review cannot finish without the deliverable Publish
285
+ // consumes. A zero-findings `ANCHORS: (none)` still satisfies this (the line exists).
286
+ export function hasFinalPassAnchors(taskDir: string): boolean {
287
+ const reviewsDir = join(taskDir, "code-reviews");
288
+ if (!existsSync(reviewsDir)) return false;
289
+ const finalPassFiles = readdirSync(reviewsDir)
290
+ .filter((f) => f.endsWith(".md") && f.includes("_final_pass-"))
291
+ .sort();
292
+ if (finalPassFiles.length === 0) return false;
293
+ const latest = finalPassFiles[finalPassFiles.length - 1];
294
+ const content = readFileSync(join(reviewsDir, latest), "utf8");
295
+ return /^ANCHORS:/m.test(content);
296
+ }
297
+
298
+ function getLatestSynthesizedPlanPath(taskDir: string): string | null {
299
+ const plansDir = join(taskDir, "plans");
300
+ if (!existsSync(plansDir)) return null;
301
+
302
+ const synthFiles = readdirSync(plansDir)
303
+ .filter((f) => f.includes("synthesized"))
304
+ .sort(sortByTimestampPrefix);
305
+ if (synthFiles.length === 0) return null;
306
+
307
+ return join(plansDir, synthFiles[synthFiles.length - 1]);
308
+ }
309
+
310
+ function extractTitle(path: string, fallback: string): string {
311
+ try {
312
+ const content = readFileSync(path, "utf-8");
313
+ for (const line of content.split("\n")) {
314
+ const m = line.match(/^#\s+(.+?)\s*$/);
315
+ if (m) return m[1];
316
+ }
317
+ } catch {
318
+ // fall through to filename fallback
319
+ }
320
+ return fallback;
321
+ }
322
+
323
+ // Path-aware manifest of on-demand task documents (artifacts/*.md + the synthesized
324
+ // plan when present) — each entry carries a REAL filesystem path an agent can read.
325
+ // Unlike getPhaseArtifacts, the plan is included whenever it exists (not phase-gated),
326
+ // so a reviewer spawned in the review phase still gets the plan path.
327
+ export function getArtifactManifest(taskDir: string): { title: string; path: string }[] {
328
+ const manifest: { title: string; path: string }[] = [];
329
+
330
+ const artifactsDir = join(taskDir, "artifacts");
331
+ if (existsSync(artifactsDir)) {
332
+ for (const file of readdirSync(artifactsDir).filter((f) => f.endsWith(".md")).sort()) {
333
+ const path = join(artifactsDir, file);
334
+ manifest.push({ title: extractTitle(path, `artifacts/${file}`), path });
335
+ }
336
+ }
337
+
338
+ const planPath = getLatestSynthesizedPlanPath(taskDir);
339
+ if (planPath) {
340
+ manifest.push({ title: "Synthesized implementation plan", path: planPath });
341
+ }
342
+
343
+ return manifest;
344
+ }
345
+
346
+ // Renders a manifest into the trailing prompt block shared by phased panels.
347
+ // Replaces the old "Do NOT re-read them from disk" line: USER_REQUEST/RESEARCH
348
+ // (and the inlined plan) stay in context, while additional artifacts are offered
349
+ // for on-demand reading from disk.
350
+ export function formatManifestBlock(manifest: { title: string; path: string }[]): string {
351
+ if (manifest.length === 0) {
352
+ return "The USER REQUEST and RESEARCH above are already in your context. Do NOT re-read them from disk.";
353
+ }
354
+ return [
355
+ "The USER REQUEST and RESEARCH above are already in your context — do NOT re-read them from disk.",
356
+ "Additional analysis artifacts and the plan are listed below; read them from disk with the read tool if relevant:",
357
+ ...manifest.map((m) => `- ${m.path} — ${m.title}`),
358
+ ].join("\n");
359
+ }
360
+
282
361
  export function loadBrainstormReviewOutputs(taskDir: string, pass: number): { name: string; content: string }[] {
283
362
  const dir = join(taskDir, "brainstorm-reviews");
284
363
  if (!existsSync(dir)) return [];
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
3
+
4
+ const theme = { fg: (_color: string, text: string) => text } as any;
5
+ const footerData = { getGitBranch: () => "main" } as any;
6
+
7
+ function render(width = 200): string[] {
8
+ const footer = createCustomFooter({} as any, theme, footerData);
9
+ return footer.render(width);
10
+ }
11
+
12
+ function makeCtx(usage?: { tokens: number | null; contextWindow: number; percent: number | null }): any {
13
+ return {
14
+ cwd: "/tmp/project",
15
+ model: { id: "test-model", provider: "test" },
16
+ sessionManager: { getSessionName: () => undefined, getEntries: () => [] },
17
+ getContextUsage: () => usage,
18
+ };
19
+ }
20
+
21
+ describe("createCustomFooter", () => {
22
+ beforeEach(() => {
23
+ setFooterTracker(undefined as any);
24
+ setFooterOrchestrator(undefined as any);
25
+ });
26
+
27
+ it("renders exactly two lines (no status/LSP line)", () => {
28
+ setFooterContext(makeCtx());
29
+ const lines = render();
30
+ expect(lines).toHaveLength(2);
31
+ });
32
+
33
+ it("line 1 shows task/phase/mode when a task is active", () => {
34
+ setFooterContext(makeCtx());
35
+ setFooterOrchestrator({
36
+ active: { type: "implement", state: { phase: "plan", mode: "autonomous" } },
37
+ } as any);
38
+ const [line1] = render();
39
+ expect(line1).toContain("task: implement");
40
+ expect(line1).toContain("phase: plan");
41
+ expect(line1).toContain("mode: autonomous");
42
+ });
43
+
44
+ it("line 1 shows autonomous mode in a read-only phase for an autonomous task", () => {
45
+ setFooterContext(makeCtx());
46
+ setFooterOrchestrator({
47
+ active: { type: "implement", state: { phase: "brainstorm", mode: "autonomous" } },
48
+ } as any);
49
+ const [line1] = render();
50
+ expect(line1).toContain("phase: brainstorm");
51
+ expect(line1).toContain("mode: autonomous");
52
+ });
53
+
54
+ it("line 1 shows guided mode for a guided task", () => {
55
+ setFooterContext(makeCtx());
56
+ setFooterOrchestrator({
57
+ active: { type: "implement", state: { phase: "implement", mode: "guided" } },
58
+ } as any);
59
+ const [line1] = render();
60
+ expect(line1).toContain("mode: guided");
61
+ });
62
+
63
+ it("line 1 omits the mode segment for a quick task", () => {
64
+ setFooterContext(makeCtx());
65
+ setFooterOrchestrator({
66
+ active: { type: "quick", state: { phase: "quick", mode: "autonomous" } },
67
+ } as any);
68
+ const [line1] = render();
69
+ expect(line1).toContain("task: quick");
70
+ expect(line1).not.toContain("mode:");
71
+ });
72
+
73
+ it("line 1 omits task metadata when no task is active", () => {
74
+ setFooterContext(makeCtx());
75
+ const [line1] = render();
76
+ expect(line1).not.toContain("task:");
77
+ expect(line1).toContain("main");
78
+ });
79
+
80
+ it("context indicator shows percent/used/max", () => {
81
+ setFooterContext(makeCtx({ tokens: 38000, contextWindow: 1000000, percent: 3.8 }));
82
+ const [, line2] = render();
83
+ expect(line2).toContain("3.8%/38k/1.0M (auto)");
84
+ });
85
+
86
+ it("context indicator degrades gracefully when tokens are null", () => {
87
+ setFooterContext(makeCtx({ tokens: null, contextWindow: 1000000, percent: null }));
88
+ const [, line2] = render();
89
+ expect(line2).toContain("?%/?/1.0M (auto)");
90
+ });
91
+ });
@@ -2,9 +2,12 @@ import { homedir } from "node:os";
2
2
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
4
4
  import type { UsageTracker } from "./usage-tracker.js";
5
+ import type { Orchestrator } from "./orchestrator.js";
6
+ import { formatModeIndicator } from "./state.js";
5
7
 
6
8
  let footerCtx: ExtensionContext | undefined;
7
9
  let footerTracker: UsageTracker | undefined;
10
+ let footerOrchestrator: Orchestrator | undefined;
8
11
 
9
12
  export function setFooterContext(ctx: ExtensionContext): void {
10
13
  footerCtx = ctx;
@@ -14,6 +17,10 @@ export function setFooterTracker(tracker: UsageTracker): void {
14
17
  footerTracker = tracker;
15
18
  }
16
19
 
20
+ export function setFooterOrchestrator(orchestrator: Orchestrator): void {
21
+ footerOrchestrator = orchestrator;
22
+ }
23
+
17
24
  function formatTokens(count: number): string {
18
25
  if (count < 1000) return count.toString();
19
26
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -22,10 +29,6 @@ function formatTokens(count: number): string {
22
29
  return `${Math.round(count / 1000000)}M`;
23
30
  }
24
31
 
25
- function sanitizeStatusText(text: string): string {
26
- return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
27
- }
28
-
29
32
  function formatPath(cwd: string): string {
30
33
  const home = homedir();
31
34
  return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
@@ -47,8 +50,10 @@ function toContextUsagePart(ctx: ExtensionContext | undefined, theme: Theme): st
47
50
  const usage = ctx?.getContextUsage();
48
51
  const contextWindow = usage?.contextWindow ?? 0;
49
52
  const percentValue = usage?.percent ?? null;
53
+ const tokensValue = usage?.tokens ?? null;
50
54
  const percentText = percentValue === null ? "?" : percentValue.toFixed(1);
51
- const display = `${percentText}%/${formatTokens(contextWindow)} (auto)`;
55
+ const tokensText = tokensValue === null ? "?" : formatTokens(tokensValue);
56
+ const display = `${percentText}%/${tokensText}/${formatTokens(contextWindow)} (auto)`;
52
57
  if (percentValue !== null && percentValue > 90) return theme.fg("error", display);
53
58
  if (percentValue !== null && percentValue > 70) return theme.fg("warning", display);
54
59
  return display;
@@ -115,23 +120,19 @@ function renderPathLine(width: number, theme: Theme, footerData: ReadonlyFooterD
115
120
  const ctx = footerCtx;
116
121
  const path = formatPath(ctx?.cwd ?? process.cwd());
117
122
  const branch = footerData.getGitBranch();
118
- const sessionName = ctx?.sessionManager.getSessionName();
119
123
 
120
124
  let line = path;
121
125
  if (branch) line += ` (${branch})`;
122
- if (sessionName) line += ` • ${sessionName}`;
123
-
124
- return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
125
- }
126
-
127
- function renderStatusLine(width: number, theme: Theme, footerData: ReadonlyFooterDataProvider): string | undefined {
128
- const statuses = footerData.getExtensionStatuses();
129
- if (statuses.size === 0) return undefined;
130
126
 
131
- const line = Array.from(statuses.entries())
132
- .sort(([a], [b]) => a.localeCompare(b))
133
- .map(([, text]) => sanitizeStatusText(text))
134
- .join(" ");
127
+ const task = footerOrchestrator?.active;
128
+ if (task && task.state.phase !== "done") {
129
+ line += ` • task: ${task.type} phase: ${task.state.phase}`;
130
+ const mode = formatModeIndicator(task.state, task.type);
131
+ if (mode) line += ` • mode: ${mode}`;
132
+ } else {
133
+ const sessionName = ctx?.sessionManager.getSessionName();
134
+ if (sessionName) line += ` • ${sessionName}`;
135
+ }
135
136
 
136
137
  return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
137
138
  }
@@ -141,8 +142,7 @@ export function createCustomFooter(_tui: TUI, theme: Theme, footerData: Readonly
141
142
  render(width: number): string[] {
142
143
  const line1 = renderPathLine(width, theme, footerData);
143
144
  const line2 = renderStatsLine(width, theme);
144
- const line3 = renderStatusLine(width, theme, footerData) ?? theme.fg("dim", "");
145
- return [line1, line2, line3];
145
+ return [line1, line2];
146
146
  },
147
147
  invalidate(): void {},
148
148
  dispose(): void {},