@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
@@ -0,0 +1,192 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("./log.js", () => ({
7
+ getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
8
+ }));
9
+
10
+ import { registerStateFileTools } from "./pp-state-tools.js";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function makeTempCwd(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-state-tools-"));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ afterEach(() => {
21
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ interface RegisteredTool {
25
+ name: string;
26
+ execute: (id: string, params: any) => Promise<any>;
27
+ }
28
+
29
+ function setup() {
30
+ const cwd = makeTempCwd();
31
+ const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
32
+ mkdirSync(join(taskDir, "artifacts"), { recursive: true });
33
+ mkdirSync(join(taskDir, "plans"), { recursive: true });
34
+
35
+ const tools = new Map<string, RegisteredTool>();
36
+ const orchestrator: any = {
37
+ cwd,
38
+ active: { dir: taskDir },
39
+ pi: { registerTool: (t: RegisteredTool) => tools.set(t.name, t) },
40
+ };
41
+ registerStateFileTools(orchestrator);
42
+ return { cwd, taskDir, orchestrator, tools };
43
+ }
44
+
45
+ function textOf(result: any): string {
46
+ return (result.content ?? []).map((c: any) => c.text).join("\n");
47
+ }
48
+
49
+ const VALID_RESEARCH = [
50
+ "## Affected Code",
51
+ "- foo.ts:bar — does a thing",
52
+ "## Architecture Context",
53
+ "- how it connects",
54
+ "## Constraints & Edge Cases",
55
+ "- MUST: keep it working",
56
+ ].join("\n");
57
+
58
+ describe("pp_write_state_file", () => {
59
+ it("creates a valid RESEARCH.md and returns compact output (no diff)", async () => {
60
+ const { taskDir, tools } = setup();
61
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
62
+ expect(res.isError).toBeFalsy();
63
+ expect(res.details).toEqual({});
64
+ expect(textOf(res)).toMatch(/^Created RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
65
+ expect(textOf(res)).not.toContain("Affected Code");
66
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toBe(VALID_RESEARCH);
67
+ });
68
+
69
+ it("reports 'Updated' on overwrite", async () => {
70
+ const { tools } = setup();
71
+ await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
72
+ const res = await tools.get("pp_write_state_file")!.execute("2", { path: "RESEARCH.md", content: VALID_RESEARCH + "\n- more" });
73
+ expect(textOf(res)).toMatch(/^Updated RESEARCH\.md/);
74
+ });
75
+
76
+ it("rejects invalid RESEARCH.md structure without writing", async () => {
77
+ const { taskDir, tools } = setup();
78
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: "## Wrong Section\nx" });
79
+ expect(res.isError).toBe(true);
80
+ expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
81
+ expect(() => readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toThrow();
82
+ });
83
+
84
+ it("accepts an artifact starting with a top-level heading", async () => {
85
+ const { tools } = setup();
86
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "artifacts/design.md", content: "# Design\n\nbody" });
87
+ expect(res.isError).toBeFalsy();
88
+ expect(textOf(res)).toMatch(/artifacts\/design\.md/);
89
+ });
90
+
91
+ it("rejects a path escaping the task dir", async () => {
92
+ const { tools } = setup();
93
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "../../../evil.md", content: "# x" });
94
+ expect(res.isError).toBe(true);
95
+ expect(textOf(res)).toMatch(/escapes the active task directory/);
96
+ });
97
+
98
+ it("rejects a non-.md file", async () => {
99
+ const { tools } = setup();
100
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "notes.txt", content: "x" });
101
+ expect(res.isError).toBe(true);
102
+ expect(textOf(res)).toMatch(/Only \.md/);
103
+ });
104
+
105
+ it("rejects managed review outputs and non-synthesized plans", async () => {
106
+ const { tools } = setup();
107
+ for (const p of [
108
+ "code-reviews/1_gpt.md",
109
+ "brainstorm-reviews/1_gemini.md",
110
+ "plan-reviews/1_opus.md",
111
+ "plans/1_gpt.md",
112
+ "scratch.md",
113
+ "artifacts/nested/deep.md",
114
+ ]) {
115
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: p, content: "# x" });
116
+ expect(res.isError, `expected ${p} to be rejected`).toBe(true);
117
+ expect(textOf(res)).toMatch(/Not an editable state file/);
118
+ }
119
+ });
120
+
121
+ it("allows the synthesized plan", async () => {
122
+ const { tools } = setup();
123
+ const plan = [
124
+ "# Plan",
125
+ "## Scope",
126
+ "Do the thing.",
127
+ "## Checklist",
128
+ "- [ ] item — Done when: it works",
129
+ ].join("\n");
130
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "plans/123_synthesized.md", content: plan });
131
+ expect(res.isError).toBeFalsy();
132
+ expect(textOf(res)).toMatch(/plans\/123_synthesized\.md/);
133
+ });
134
+ });
135
+
136
+ describe("pp_edit_state_file", () => {
137
+ beforeEach(() => {});
138
+
139
+ it("replaces a unique span and returns compact output", async () => {
140
+ const { taskDir, tools } = setup();
141
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
142
+ const res = await tools.get("pp_edit_state_file")!.execute("1", {
143
+ path: "RESEARCH.md",
144
+ oldText: "does a thing",
145
+ newText: "does a different thing",
146
+ });
147
+ expect(res.isError).toBeFalsy();
148
+ expect(textOf(res)).toMatch(/^Updated RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
149
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("does a different thing");
150
+ });
151
+
152
+ it("errors when oldText is not found", async () => {
153
+ const { taskDir, tools } = setup();
154
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
155
+ const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "nope", newText: "x" });
156
+ expect(res.isError).toBe(true);
157
+ expect(textOf(res)).toMatch(/not found/);
158
+ });
159
+
160
+ it("errors on ambiguous oldText unless replaceAll", async () => {
161
+ const { taskDir, tools } = setup();
162
+ // Use an artifact (an allowed, unstructured-body state file) with duplicate spans.
163
+ writeFileSync(join(taskDir, "artifacts", "dup.md"), "# Dup\nx\nx\n", "utf-8");
164
+ const ambiguous = await tools.get("pp_edit_state_file")!.execute("1", { path: "artifacts/dup.md", oldText: "x", newText: "y" });
165
+ expect(ambiguous.isError).toBe(true);
166
+ expect(textOf(ambiguous)).toMatch(/matches 2 locations/);
167
+
168
+ const all = await tools.get("pp_edit_state_file")!.execute("2", { path: "artifacts/dup.md", oldText: "x", newText: "y", replaceAll: true });
169
+ expect(all.isError).toBeFalsy();
170
+ expect(readFileSync(join(taskDir, "artifacts", "dup.md"), "utf-8")).toBe("# Dup\ny\ny\n");
171
+ });
172
+
173
+ it("errors when the file does not exist", async () => {
174
+ const { tools } = setup();
175
+ const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "a", newText: "b" });
176
+ expect(res.isError).toBe(true);
177
+ expect(textOf(res)).toMatch(/does not exist/);
178
+ });
179
+
180
+ it("rejects an edit that breaks structure without writing", async () => {
181
+ const { taskDir, tools } = setup();
182
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
183
+ const res = await tools.get("pp_edit_state_file")!.execute("1", {
184
+ path: "RESEARCH.md",
185
+ oldText: "## Affected Code",
186
+ newText: "## Renamed Section",
187
+ });
188
+ expect(res.isError).toBe(true);
189
+ expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
190
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("## Affected Code");
191
+ });
192
+ });
@@ -0,0 +1,249 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
3
+ import { Type } from "@sinclair/typebox";
4
+ import type { Orchestrator } from "./orchestrator.js";
5
+ import { getLogger } from "./log.js";
6
+ import {
7
+ validateArtifact,
8
+ validatePlan,
9
+ validateResearch,
10
+ validateUserRequest,
11
+ } from "./validate-artifacts.js";
12
+
13
+ type ToolResult = {
14
+ content: { type: "text"; text: string }[];
15
+ isError?: true;
16
+ details: Record<string, never>;
17
+ };
18
+
19
+ function ok(text: string): ToolResult {
20
+ return { content: [{ type: "text" as const, text }], details: {} };
21
+ }
22
+
23
+ function err(text: string): ToolResult {
24
+ return { content: [{ type: "text" as const, text }], isError: true as const, details: {} };
25
+ }
26
+
27
+ function isPathInside(basePath: string, targetPath: string): boolean {
28
+ const rel = relative(basePath, targetPath);
29
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
30
+ }
31
+
32
+ // Approximate added/removed line counts via an LCS of the line arrays. Good
33
+ // enough for a compact "(+N/-M lines)" summary without pulling in a diff lib.
34
+ function lineDelta(oldContent: string, newContent: string): { added: number; removed: number } {
35
+ const a = oldContent.length ? oldContent.split(/\r?\n/) : [];
36
+ const b = newContent.length ? newContent.split(/\r?\n/) : [];
37
+ const n = a.length;
38
+ const m = b.length;
39
+ // DP LCS length (rolling rows to keep memory O(m)).
40
+ let prev = new Array<number>(m + 1).fill(0);
41
+ for (let i = 1; i <= n; i += 1) {
42
+ const curr = new Array<number>(m + 1).fill(0);
43
+ for (let j = 1; j <= m; j += 1) {
44
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]);
45
+ }
46
+ prev = curr;
47
+ }
48
+ const lcs = prev[m];
49
+ return { added: m - lcs, removed: n - lcs };
50
+ }
51
+
52
+ // The exact set of state files these compact tools may edit. Anything else
53
+ // under the task dir — especially orchestrator-MANAGED outputs like
54
+ // brainstorm-reviews/, plan-reviews/, code-reviews/, and non-synthesized
55
+ // plans/ files — is REJECTED so the agent cannot corrupt managed artifacts.
56
+ function isAllowedStateFile(rel: string): boolean {
57
+ if (rel === "USER_REQUEST.md" || rel === "RESEARCH.md") return true;
58
+ const parts = rel.split(sep);
59
+ const base = parts[parts.length - 1];
60
+ if (parts.length === 2 && parts[0] === "artifacts" && base.endsWith(".md")) return true;
61
+ if (parts.length === 2 && parts[0] === "plans" && base.endsWith("_synthesized.md")) return true;
62
+ return false;
63
+ }
64
+
65
+ // Resolve a caller-supplied path (relative to the active task dir, or absolute)
66
+ // and enforce it stays within the active task dir under .pp/state/, is a .md
67
+ // file, AND is one of the allowed state files. Returns the absolute path or an
68
+ // error result.
69
+ function resolveStatePath(
70
+ orchestrator: Orchestrator,
71
+ rawPath: string,
72
+ ): { ok: true; absolute: string; label: string } | { ok: false; result: ToolResult } {
73
+ const active = orchestrator.active;
74
+ if (!active) return { ok: false, result: err("No active task.") };
75
+ const taskDir = resolve(active.dir);
76
+ const input = typeof rawPath === "string" ? rawPath.trim() : "";
77
+ if (!input) return { ok: false, result: err("Missing path.") };
78
+
79
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(taskDir, input);
80
+
81
+ if (!isPathInside(taskDir, absolute)) {
82
+ return { ok: false, result: err(`Path escapes the active task directory: ${rawPath}`) };
83
+ }
84
+ const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
85
+ if (!isPathInside(ppStateDir, absolute)) {
86
+ return { ok: false, result: err("State files must live under .pp/state/.") };
87
+ }
88
+ if (!absolute.endsWith(".md")) {
89
+ return { ok: false, result: err("Only .md state files can be edited with this tool.") };
90
+ }
91
+ const rel = relative(taskDir, absolute);
92
+ if (!isAllowedStateFile(rel)) {
93
+ return {
94
+ ok: false,
95
+ result: err(
96
+ `Not an editable state file: ${rel}. These tools only edit USER_REQUEST.md, RESEARCH.md, artifacts/*.md, and plans/*_synthesized.md. ` +
97
+ `Managed outputs (brainstorm-reviews/, plan-reviews/, code-reviews/, non-synthesized plans) are off-limits.`,
98
+ ),
99
+ };
100
+ }
101
+ const label = rel || relative(orchestrator.cwd, absolute);
102
+ return { ok: true, absolute, label };
103
+ }
104
+
105
+ // Validate structured state files by their role. resolveStatePath has already
106
+ // restricted callers to the allowed set (USER_REQUEST/RESEARCH/artifacts/
107
+ // plans-synthesized), so every path reaching here maps to a known validator.
108
+ function validateStateContent(taskDir: string, absolute: string, content: string):
109
+ | { ok: true }
110
+ | { ok: false; errors: string[]; hint: string } {
111
+ const rel = relative(taskDir, absolute);
112
+ const parts = rel.split(sep);
113
+ const base = parts[parts.length - 1];
114
+
115
+ if (rel === "USER_REQUEST.md") {
116
+ const res = validateUserRequest(content);
117
+ return res.ok
118
+ ? { ok: true }
119
+ : { ok: false, errors: res.errors, hint: "Keep exactly: # User Request, ## Problem, ## Constraints. No other sections." };
120
+ }
121
+ if (rel === "RESEARCH.md") {
122
+ const res = validateResearch(content);
123
+ return res.ok
124
+ ? { ok: true }
125
+ : {
126
+ ok: false,
127
+ errors: res.errors,
128
+ hint: "Keep exactly: ## Affected Code, ## Architecture Context, ## Constraints & Edge Cases, ## Open Questions (optional). No other sections.",
129
+ };
130
+ }
131
+ if (parts[0] === "artifacts" && base.endsWith(".md")) {
132
+ const res = validateArtifact(content);
133
+ return res.ok ? { ok: true } : { ok: false, errors: res.errors, hint: "Artifact files must start with # <Title>." };
134
+ }
135
+ if (parts[0] === "plans" && base.endsWith("_synthesized.md")) {
136
+ const res = validatePlan(content);
137
+ return res.ok
138
+ ? { ok: true }
139
+ : { ok: false, errors: res.errors, hint: "Keep exactly: # Plan, ## Scope, ## Checklist (items need 'Done when:'), ## Blockers (optional)." };
140
+ }
141
+ return { ok: true };
142
+ }
143
+
144
+ function validationError(label: string, errors: string[], hint: string): ToolResult {
145
+ return err(
146
+ `${label} structure is invalid:\n${errors.map((e) => `- ${e}`).join("\n")}\n\n${hint}`,
147
+ );
148
+ }
149
+
150
+ // Dedicated compact-output tools for editing .pp state files. Unlike the generic
151
+ // write/edit tools (which render the full file content / a unified diff into the
152
+ // UI on every update — the "state-file spam"), these return only a one-line
153
+ // summary and no details.diff, so the TUI shows nothing large. Structure is
154
+ // validated inline (rejecting bad writes up front) instead of via an appended
155
+ // <validation-error> round-trip on the generic tools.
156
+ export function registerStateFileTools(orchestrator: Orchestrator): void {
157
+ const pi = orchestrator.pi;
158
+ const log = getLogger();
159
+
160
+ pi.registerTool({
161
+ name: "pp_write_state_file",
162
+ label: "pi-pi",
163
+ description:
164
+ "Create or overwrite a pi-pi state file (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
165
+ "or plans/*_synthesized.md) under the active task directory. PREFER this over the generic " +
166
+ "write tool for .pp state files: it emits compact output (no full-file echo) and validates " +
167
+ "structure before writing. `path` is relative to the task directory (e.g. \"RESEARCH.md\", " +
168
+ "\"artifacts/design.md\").",
169
+ parameters: Type.Object({
170
+ path: Type.String({ description: "Path relative to the active task dir (e.g. RESEARCH.md, artifacts/foo.md)" }),
171
+ content: Type.String({ description: "Full new file content" }),
172
+ }),
173
+ async execute(_toolCallId, params: any): Promise<ToolResult> {
174
+ const resolved = resolveStatePath(orchestrator, params?.path);
175
+ if (!resolved.ok) return resolved.result;
176
+ const content = typeof params?.content === "string" ? params.content : "";
177
+
178
+ const taskDir = resolve(orchestrator.active!.dir);
179
+ const check = validateStateContent(taskDir, resolved.absolute, content);
180
+ if (!check.ok) return validationError(resolved.label, check.errors, check.hint);
181
+
182
+ const existed = existsSync(resolved.absolute);
183
+ const before = existed ? readFileSync(resolved.absolute, "utf-8") : "";
184
+ try {
185
+ mkdirSync(dirname(resolved.absolute), { recursive: true });
186
+ writeFileSync(resolved.absolute, content, "utf-8");
187
+ } catch (e: any) {
188
+ return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
189
+ }
190
+ const { added, removed } = lineDelta(before, content);
191
+ log.debug({ s: "tool", tool: "pp_write_state_file", path: resolved.label, added, removed }, "state file written");
192
+ const verb = existed ? "Updated" : "Created";
193
+ return ok(`${verb} ${resolved.label} (+${added}/-${removed} lines)`);
194
+ },
195
+ });
196
+
197
+ pi.registerTool({
198
+ name: "pp_edit_state_file",
199
+ label: "pi-pi",
200
+ description:
201
+ "Edit a pi-pi state file in place by replacing an exact text span. PREFER this over the " +
202
+ "generic edit tool for .pp state files (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
203
+ "plans/*_synthesized.md): it emits compact output (no diff) and validates structure before " +
204
+ "writing. `path` is relative to the active task dir. `oldText` must match exactly and be " +
205
+ "unique unless replaceAll is set.",
206
+ parameters: Type.Object({
207
+ path: Type.String({ description: "Path relative to the active task dir (e.g. RESEARCH.md)" }),
208
+ oldText: Type.String({ description: "Exact text to replace" }),
209
+ newText: Type.String({ description: "Replacement text" }),
210
+ replaceAll: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: false — oldText must be unique)" })),
211
+ }),
212
+ async execute(_toolCallId, params: any): Promise<ToolResult> {
213
+ const resolved = resolveStatePath(orchestrator, params?.path);
214
+ if (!resolved.ok) return resolved.result;
215
+ if (!existsSync(resolved.absolute)) {
216
+ return err(`File does not exist: ${resolved.label}. Use pp_write_state_file to create it.`);
217
+ }
218
+ const oldText = typeof params?.oldText === "string" ? params.oldText : "";
219
+ const newText = typeof params?.newText === "string" ? params.newText : "";
220
+ const replaceAll = params?.replaceAll === true;
221
+ if (!oldText) return err("oldText must be a non-empty string.");
222
+
223
+ const before = readFileSync(resolved.absolute, "utf-8");
224
+ const occurrences = before.split(oldText).length - 1;
225
+ if (occurrences === 0) return err(`oldText not found in ${resolved.label}.`);
226
+ if (occurrences > 1 && !replaceAll) {
227
+ return err(`oldText matches ${occurrences} locations in ${resolved.label}. Make it unique or set replaceAll.`);
228
+ }
229
+ const after = replaceAll ? before.split(oldText).join(newText) : before.replace(oldText, newText);
230
+
231
+ const taskDir = resolve(orchestrator.active!.dir);
232
+ const check = validateStateContent(taskDir, resolved.absolute, after);
233
+ if (!check.ok) return validationError(resolved.label, check.errors, check.hint);
234
+
235
+ try {
236
+ writeFileSync(resolved.absolute, after, "utf-8");
237
+ } catch (e: any) {
238
+ return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
239
+ }
240
+ const { added, removed } = lineDelta(before, after);
241
+ log.debug(
242
+ { s: "tool", tool: "pp_edit_state_file", path: resolved.label, occurrences, replaceAll, added, removed },
243
+ "state file edited",
244
+ );
245
+ const scope = replaceAll ? ` (${occurrences}x)` : "";
246
+ return ok(`Updated ${resolved.label}${scope} (+${added}/-${removed} lines)`);
247
+ },
248
+ });
249
+ }
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock collaborators so the flow test exercises only the fallback orchestration
4
+ // (dialog gating, main-vs-subagent switch gating, override activation).
5
+ vi.mock("./log.js", () => ({
6
+ getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
7
+ }));
8
+
9
+ const askUserMock = vi.fn();
10
+ vi.mock("../../3p/pi-ask-user/index.js", () => ({
11
+ askUser: (...args: any[]) => askUserMock(...args),
12
+ isCancel: (v: any) => !!v && v.__cancel === true,
13
+ }));
14
+
15
+ vi.mock("./flant-infra.js", () => ({
16
+ loadFlantSettings: () => ({ switchBackIntervalMinutes: 30 }),
17
+ probeSubscriptionCleared: vi.fn(),
18
+ SUB_PROVIDER: "pp-flant-anthropic-sub",
19
+ SUB_MODEL_PREFIX: "sub/",
20
+ }));
21
+
22
+ const setSubscriptionFallbackActiveMock = vi.fn();
23
+ vi.mock("./model-registry.js", () => ({
24
+ setSubscriptionFallbackActive: (v: boolean) => setSubscriptionFallbackActiveMock(v),
25
+ toNonSubSpec: (spec: string) =>
26
+ spec.replace(/^pp-flant-anthropic-sub\/sub\//, "pp-flant-anthropic/").replace(/^sub\//, "pp-flant-anthropic/"),
27
+ }));
28
+
29
+ import { handleMainRateLimit, handleSubagentRateLimit } from "./rate-limit-fallback.js";
30
+
31
+ function makeOrchestrator() {
32
+ return {
33
+ active: { state: { phase: "debug" } },
34
+ activeTaskToken: 1,
35
+ subFallbackActive: false,
36
+ subFallbackDialogPending: false,
37
+ subFallbackPendingDecision: false,
38
+ subFallbackModelId: null as string | null,
39
+ subSwitchBackTimer: null as any,
40
+ config: { agents: { orchestrators: { debug: { thinking: "high" }, implement: { thinking: "high" } } } },
41
+ switchModel: vi.fn().mockResolvedValue(true),
42
+ sendUserMessageWhenIdle: vi.fn(),
43
+ cancelPendingRetry: vi.fn(),
44
+ safeSendUserMessage: vi.fn(),
45
+ } as any;
46
+ }
47
+
48
+ function makeCtx() {
49
+ return { hasUI: true, abort: vi.fn(), ui: { notify: vi.fn() } };
50
+ }
51
+
52
+ beforeEach(() => {
53
+ askUserMock.mockReset();
54
+ setSubscriptionFallbackActiveMock.mockReset();
55
+ });
56
+ afterEach(() => {
57
+ vi.clearAllTimers();
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ describe("handleSubagentRateLimit (M1: does not switch the main model)", () => {
62
+ it("activates the session override + nudges but does NOT switch the main model", async () => {
63
+ vi.useFakeTimers();
64
+ askUserMock.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
65
+ const orch = makeOrchestrator();
66
+
67
+ await handleSubagentRateLimit(orch, makeCtx(), "pp-flant-anthropic-sub/sub/claude-opus-4-8");
68
+
69
+ expect(setSubscriptionFallbackActiveMock).toHaveBeenCalledWith(true);
70
+ expect(orch.subFallbackActive).toBe(true);
71
+ // Main model must NOT change for a subagent-origin 429.
72
+ expect(orch.switchModel).not.toHaveBeenCalled();
73
+ // Continuation nudge is idle-gated (not a bare safeSendUserMessage).
74
+ expect(orch.sendUserMessageWhenIdle).toHaveBeenCalledTimes(1);
75
+ expect(orch.safeSendUserMessage).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("does nothing for a non-subscription subagent model", async () => {
79
+ const orch = makeOrchestrator();
80
+ await handleSubagentRateLimit(orch, makeCtx(), "pp-flant-anthropic/claude-opus-4-8");
81
+ expect(askUserMock).not.toHaveBeenCalled();
82
+ expect(orch.subFallbackActive).toBe(false);
83
+ });
84
+
85
+ it("clears the pending-decision flag after the dialog resolves", async () => {
86
+ vi.useFakeTimers();
87
+ askUserMock.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
88
+ const orch = makeOrchestrator();
89
+ orch.subFallbackPendingDecision = true; // set synchronously by the detection site
90
+ await handleSubagentRateLimit(orch, makeCtx(), "pp-flant-anthropic-sub/sub/claude-opus-4-8");
91
+ expect(orch.subFallbackPendingDecision).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("handleMainRateLimit (main-origin switches the main model)", () => {
96
+ it("switches the main model to the non-sub equivalent on confirm", async () => {
97
+ vi.useFakeTimers();
98
+ askUserMock.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
99
+ const orch = makeOrchestrator();
100
+ const ctx = makeCtx();
101
+
102
+ await handleMainRateLimit(orch, ctx, "pp-flant-anthropic-sub/sub/claude-opus-4-8", "pp-flant-anthropic-sub");
103
+
104
+ // Futile retry stopped.
105
+ expect(ctx.abort).toHaveBeenCalled();
106
+ expect(orch.cancelPendingRetry).toHaveBeenCalled();
107
+ // Session override on + main model switched to non-sub.
108
+ expect(setSubscriptionFallbackActiveMock).toHaveBeenCalledWith(true);
109
+ expect(orch.switchModel).toHaveBeenCalledWith(ctx, "pp-flant-anthropic/claude-opus-4-8", "high");
110
+ expect(orch.sendUserMessageWhenIdle).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it("declining does not switch or activate the override", async () => {
114
+ askUserMock.mockResolvedValue({ kind: "selection", selections: ["Stay on subscription"] });
115
+ const orch = makeOrchestrator();
116
+ await handleMainRateLimit(orch, makeCtx(), "pp-flant-anthropic-sub/sub/claude-opus-4-8", "pp-flant-anthropic-sub");
117
+ expect(orch.switchModel).not.toHaveBeenCalled();
118
+ expect(setSubscriptionFallbackActiveMock).not.toHaveBeenCalledWith(true);
119
+ expect(orch.subFallbackActive).toBe(false);
120
+ });
121
+
122
+ it("is sticky: no dialog when already on fallback", async () => {
123
+ const orch = makeOrchestrator();
124
+ orch.subFallbackActive = true;
125
+ await handleMainRateLimit(orch, makeCtx(), "pp-flant-anthropic-sub/sub/claude-opus-4-8", "pp-flant-anthropic-sub");
126
+ expect(askUserMock).not.toHaveBeenCalled();
127
+ });
128
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
3
+ import { isSubscriptionRouted } from "./usage-tracker.js";
4
+ import { SUB_MODEL_PREFIX, SUB_PROVIDER, subProbeModelId } from "./flant-infra.js";
5
+
6
+ describe("isRateLimitError", () => {
7
+ it("matches 429 and rate-limit phrasings", () => {
8
+ expect(isRateLimitError("Error 429: too many requests")).toBe(true);
9
+ expect(isRateLimitError("rate_limit_error")).toBe(true);
10
+ expect(isRateLimitError("This request would exceed your account's rate limit")).toBe(true);
11
+ expect(isRateLimitError("Rate Limit reached")).toBe(true);
12
+ expect(isRateLimitError("Too Many Requests")).toBe(true);
13
+ });
14
+
15
+ it("does not match unrelated errors", () => {
16
+ expect(isRateLimitError("Anthropic stream ended before message_stop")).toBe(false);
17
+ expect(isRateLimitError("500 internal server error")).toBe(false);
18
+ expect(isRateLimitError("")).toBe(false);
19
+ expect(isRateLimitError(undefined)).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("isSdkRetryableError", () => {
24
+ it("matches the same error classes the SDK auto-retries", () => {
25
+ // These are exactly the errors the user hit; the SDK retries them itself,
26
+ // so pi-pi must NOT double-retry.
27
+ expect(isSdkRetryableError("Anthropic stream ended before message_stop")).toBe(true);
28
+ expect(isSdkRetryableError("429 rate_limit_error")).toBe(true);
29
+ expect(isSdkRetryableError("overloaded_error")).toBe(true);
30
+ expect(isSdkRetryableError("503 service unavailable")).toBe(true);
31
+ expect(isSdkRetryableError("fetch failed")).toBe(true);
32
+ expect(isSdkRetryableError("request timed out")).toBe(true);
33
+ });
34
+
35
+ it("does not match non-retryable errors", () => {
36
+ expect(isSdkRetryableError("invalid request: bad tool arguments")).toBe(false);
37
+ expect(isSdkRetryableError("")).toBe(false);
38
+ expect(isSdkRetryableError(undefined)).toBe(false);
39
+ });
40
+
41
+ it("the common rate-limit phrasings are both rate-limit AND SDK-retryable", () => {
42
+ // The sub-429 interception (isRateLimitError) runs BEFORE the SDK-defer
43
+ // branch (isSdkRetryableError); the phrasings the gateway actually emits
44
+ // must be recognised by both so a sub-429 is intercepted and any other
45
+ // rate-limit is deferred to the SDK rather than double-retried by pi-pi.
46
+ for (const m of ["429", "rate limit", "rate_limit_error", "too many requests"]) {
47
+ expect(isRateLimitError(m)).toBe(true);
48
+ expect(isSdkRetryableError(m)).toBe(true);
49
+ }
50
+ // The gateway's account-limit phrasing is a rate limit; the SDK regex does
51
+ // not include that exact phrase, so pi-pi's sub-429 interception (which runs
52
+ // first) is what handles it — by design.
53
+ expect(isRateLimitError("exceed your account")).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("isSubscriptionRouted", () => {
58
+ it("detects the subscription provider", () => {
59
+ expect(isSubscriptionRouted("claude-opus-4-8", SUB_PROVIDER)).toBe(true);
60
+ });
61
+
62
+ it("detects the sub/ model prefix", () => {
63
+ expect(isSubscriptionRouted(`${SUB_MODEL_PREFIX}claude-opus-4-8`)).toBe(true);
64
+ });
65
+
66
+ it("detects a full provider-prefixed spec passed as a single id", () => {
67
+ expect(isSubscriptionRouted(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}claude-opus-4-8`)).toBe(true);
68
+ });
69
+
70
+ it("returns false for regular flant Claude", () => {
71
+ expect(isSubscriptionRouted("claude-opus-4-8", "pp-flant-anthropic")).toBe(false);
72
+ expect(isSubscriptionRouted("pp-flant-anthropic/claude-opus-4-8")).toBe(false);
73
+ });
74
+
75
+ it("returns false with no signal", () => {
76
+ expect(isSubscriptionRouted(undefined, undefined)).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("subProbeModelId", () => {
81
+ it("derives sub/<m> from a provider-prefixed spec", () => {
82
+ expect(subProbeModelId(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}claude-opus-4-8`)).toBe(`${SUB_MODEL_PREFIX}claude-opus-4-8`);
83
+ });
84
+
85
+ it("keeps a bare sub/<m> spec", () => {
86
+ expect(subProbeModelId(`${SUB_MODEL_PREFIX}claude-haiku-4-5`)).toBe(`${SUB_MODEL_PREFIX}claude-haiku-4-5`);
87
+ });
88
+
89
+ it("adds sub/ to a plain claude id", () => {
90
+ expect(subProbeModelId("claude-opus-4-8")).toBe(`${SUB_MODEL_PREFIX}claude-opus-4-8`);
91
+ });
92
+
93
+ it("never double-prefixes the provider", () => {
94
+ const out = subProbeModelId(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}claude-opus-4-8`);
95
+ expect(out).not.toContain(SUB_PROVIDER);
96
+ expect(out.startsWith(`${SUB_MODEL_PREFIX}${SUB_MODEL_PREFIX}`)).toBe(false);
97
+ });
98
+ });