@ilya-lesikov/pi-pi 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -1,53 +1,91 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
|
|
3
4
|
import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
|
|
4
5
|
import { Type } from "@sinclair/typebox";
|
|
5
|
-
import { loadConfig } from "./config.js";
|
|
6
|
-
import { runAfterEdit, autoCommit } from "./commands.js";
|
|
7
|
-
import { taskName, getActiveTask, saveTask,
|
|
6
|
+
import { loadConfig, resolvePreset } from "./config.js";
|
|
7
|
+
import { runAfterEdit, autoCommit, loadRepoAfterEditCommands } from "./commands.js";
|
|
8
|
+
import { taskName, getActiveTask, getEffectiveMode, getEffectivePhaseMode, saveTask, type Phase, type TaskMode } from "./state.js";
|
|
9
|
+
import { getLogger, initSessionLogger, addTaskDestination, setLogLevel, flushLogs } from "./log.js";
|
|
10
|
+
import { initTracer, finalizeTracer, getTracer } from "./tracer.js";
|
|
11
|
+
import { handleSpawnResult } from "./spawn-cleanup.js";
|
|
8
12
|
import {
|
|
9
|
-
|
|
13
|
+
getContextDirs,
|
|
14
|
+
loadAllContextFiles,
|
|
10
15
|
getPhaseArtifacts,
|
|
11
16
|
getLatestSynthesizedPlan,
|
|
12
17
|
loadBrainstormReviewOutputs,
|
|
13
18
|
loadCodeReviewOutputs,
|
|
14
19
|
loadPlanReviewOutputs,
|
|
15
20
|
} from "./context.js";
|
|
16
|
-
import {
|
|
21
|
+
import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
|
|
22
|
+
import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
|
|
17
23
|
import { registerCbmTools } from "./cbm.js";
|
|
18
24
|
import { registerExaTools } from "./exa.js";
|
|
19
25
|
import { registerAstSearchTool } from "./ast-search.js";
|
|
20
26
|
import { SUBAGENT_SESSION_KEY } from "./index.js";
|
|
27
|
+
import { registerCommandHandlers } from "./command-handlers.js";
|
|
21
28
|
import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
|
|
29
|
+
import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
|
|
22
30
|
import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
|
|
23
31
|
import { spawnCodeReviewers } from "./phases/review.js";
|
|
24
32
|
import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
|
|
33
|
+
import { reviewPassUnanimousApprove } from "./phases/verdict.js";
|
|
34
|
+
import { validateExitCriteria } from "./phases/machine.js";
|
|
25
35
|
import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
|
|
26
|
-
import { Orchestrator,
|
|
36
|
+
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
27
37
|
import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
|
|
28
38
|
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
|
|
29
|
-
import { askUser } from "../../3p/pi-ask-user/index.js";
|
|
39
|
+
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
30
40
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
|
|
31
42
|
|
|
32
43
|
const USAGE_TRACKER_KEY = Symbol.for("pi-pi:usage-tracker");
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
function isEnabled(value: { enabled?: boolean } | undefined): boolean {
|
|
46
|
+
return value?.enabled !== false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPathInside(basePath: string, targetPath: string): boolean {
|
|
50
|
+
const rel = relative(basePath, targetPath);
|
|
51
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
|
|
55
|
+
const normalizedPath = normalizeRepoPath(repoPath);
|
|
56
|
+
const repo = repos.find((r) => r.path === normalizedPath);
|
|
57
|
+
if (repo?.baseBranch) return repo.baseBranch;
|
|
58
|
+
|
|
36
59
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
60
|
+
const headRef = await orchestrator.pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
61
|
+
cwd: normalizedPath,
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
});
|
|
64
|
+
if (headRef.code === 0) {
|
|
65
|
+
const value = headRef.stdout.trim();
|
|
66
|
+
if (value.startsWith("refs/remotes/")) {
|
|
67
|
+
return value.slice("refs/remotes/".length);
|
|
68
|
+
}
|
|
40
69
|
}
|
|
41
70
|
} catch {}
|
|
71
|
+
|
|
42
72
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
73
|
+
const mainRef = await orchestrator.pi.exec("git", ["show-ref", "--verify", "--quiet", "refs/remotes/origin/main"], {
|
|
74
|
+
cwd: normalizedPath,
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
});
|
|
77
|
+
if (mainRef.code === 0) return "origin/main";
|
|
45
78
|
} catch {}
|
|
79
|
+
|
|
46
80
|
try {
|
|
47
|
-
const
|
|
48
|
-
|
|
81
|
+
const masterRef = await orchestrator.pi.exec("git", ["show-ref", "--verify", "--quiet", "refs/remotes/origin/master"], {
|
|
82
|
+
cwd: normalizedPath,
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
});
|
|
85
|
+
if (masterRef.code === 0) return "origin/master";
|
|
49
86
|
} catch {}
|
|
50
|
-
|
|
87
|
+
|
|
88
|
+
return "origin/main";
|
|
51
89
|
}
|
|
52
90
|
|
|
53
91
|
export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
|
|
@@ -58,11 +96,77 @@ export async function selectOption(ctx: any, question: string, options: string[]
|
|
|
58
96
|
allowComment: false,
|
|
59
97
|
allowMultiple: false,
|
|
60
98
|
});
|
|
61
|
-
if (!result || result.kind !== "selection") return undefined;
|
|
99
|
+
if (!result || isCancel(result) || result.kind !== "selection") return undefined;
|
|
62
100
|
return result.selections[0];
|
|
63
101
|
}
|
|
64
102
|
|
|
65
|
-
function
|
|
103
|
+
function resolveReviewers(
|
|
104
|
+
orchestrator: Orchestrator,
|
|
105
|
+
phase: string,
|
|
106
|
+
presetName?: string,
|
|
107
|
+
): Record<string, any> {
|
|
108
|
+
const group = phase === "brainstorm"
|
|
109
|
+
? "brainstormReviewers"
|
|
110
|
+
: phase === "plan"
|
|
111
|
+
? "planReviewers"
|
|
112
|
+
: "codeReviewers";
|
|
113
|
+
return resolvePreset(orchestrator.config, group, presetName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getDefaultReviewPresetName(orchestrator: Orchestrator, phase: string): string {
|
|
117
|
+
if (phase === "brainstorm") return orchestrator.config.agents.subagents.presetGroups.brainstormReviewers.default;
|
|
118
|
+
if (phase === "plan") return orchestrator.config.agents.subagents.presetGroups.planReviewers.default;
|
|
119
|
+
return orchestrator.config.agents.subagents.presetGroups.codeReviewers.default;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeStoredPlannerPresetName(orchestrator: Orchestrator): string {
|
|
123
|
+
const requestedName = orchestrator.active?.state.activePlannerPreset ?? orchestrator.config.agents.subagents.presetGroups.planners.default;
|
|
124
|
+
const plannerPresets = orchestrator.config.agents.subagents.presetGroups.planners.presets ?? {};
|
|
125
|
+
const exists = Object.prototype.hasOwnProperty.call(plannerPresets, requestedName);
|
|
126
|
+
const resolvedName = exists ? requestedName : (Object.keys(plannerPresets)[0] ?? requestedName);
|
|
127
|
+
|
|
128
|
+
if (orchestrator.active && orchestrator.active.state.activePlannerPreset !== resolvedName) {
|
|
129
|
+
orchestrator.active.state.activePlannerPreset = resolvedName;
|
|
130
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!exists && resolvedName !== requestedName) {
|
|
134
|
+
orchestrator.lastCtx?.ui?.notify(
|
|
135
|
+
`Planner preset "${requestedName}" not found. Falling back to "${resolvedName}".`,
|
|
136
|
+
"warning",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return resolvedName;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeStoredReviewPresetName(orchestrator: Orchestrator, phase: string): string {
|
|
144
|
+
const group = phase === "brainstorm"
|
|
145
|
+
? "brainstormReviewers"
|
|
146
|
+
: phase === "plan"
|
|
147
|
+
? "planReviewers"
|
|
148
|
+
: "codeReviewers";
|
|
149
|
+
const requestedName = orchestrator.active?.state.activeReviewPreset ?? getDefaultReviewPresetName(orchestrator, phase);
|
|
150
|
+
const reviewPresets = orchestrator.config.agents.subagents.presetGroups[group].presets ?? {};
|
|
151
|
+
const exists = Object.prototype.hasOwnProperty.call(reviewPresets, requestedName);
|
|
152
|
+
const resolvedName = exists ? requestedName : (Object.keys(reviewPresets)[0] ?? requestedName);
|
|
153
|
+
|
|
154
|
+
if (orchestrator.active && orchestrator.active.state.activeReviewPreset !== resolvedName) {
|
|
155
|
+
orchestrator.active.state.activeReviewPreset = resolvedName;
|
|
156
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!exists && resolvedName !== requestedName) {
|
|
160
|
+
orchestrator.lastCtx?.ui?.notify(
|
|
161
|
+
`Review preset "${requestedName}" not found. Falling back to "${resolvedName}".`,
|
|
162
|
+
"warning",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return resolvedName;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: number): void {
|
|
66
170
|
if (
|
|
67
171
|
!orchestrator.active?.state.reviewCycle ||
|
|
68
172
|
orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
|
|
@@ -70,41 +174,60 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator): void {
|
|
|
70
174
|
orchestrator.pendingSubagentSpawns > 0
|
|
71
175
|
) return;
|
|
72
176
|
|
|
73
|
-
|
|
74
|
-
|
|
177
|
+
// Idempotent by state: the first call mutates reviewCycle.step away from
|
|
178
|
+
// "await_reviewers" (or nulls reviewCycle), so the guard above no-ops any
|
|
179
|
+
// subsequent caller. No separate dedup token needed.
|
|
75
180
|
const cycle = orchestrator.active.state.reviewCycle;
|
|
76
181
|
const phase = orchestrator.active.state.phase;
|
|
77
182
|
const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
|
|
78
183
|
const pi = orchestrator.pi;
|
|
79
184
|
|
|
80
|
-
|
|
185
|
+
if (spawnedReviewers === 0 && outputs.length === 0) {
|
|
186
|
+
orchestrator.active.state.reviewCycle = null;
|
|
187
|
+
orchestrator.active.state.step = "llm_work";
|
|
188
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
189
|
+
orchestrator.safeSendUserMessage("[PI-PI] No reviewer outputs were produced — nothing to review. Continue working.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
81
193
|
cycle.step = "apply_feedback";
|
|
82
194
|
orchestrator.active.state.step = "apply_feedback";
|
|
83
195
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
84
196
|
|
|
85
197
|
const rendered = outputs.length
|
|
86
198
|
? outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n")
|
|
87
|
-
: "
|
|
199
|
+
: "All reviewers failed to produce output. Review the work yourself and decide whether to approve or request changes.";
|
|
88
200
|
|
|
89
|
-
|
|
201
|
+
orchestrator.transitionController.sendCustom(
|
|
90
202
|
{
|
|
91
203
|
customType: "pp-review-ready",
|
|
92
204
|
content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`,
|
|
93
205
|
display: false,
|
|
94
206
|
},
|
|
95
|
-
|
|
207
|
+
"instruction",
|
|
96
208
|
);
|
|
97
|
-
|
|
209
|
+
orchestrator.safeSendUserMessage(reviewReadyMessage(phase));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function reviewReadyMessage(phase: string): string {
|
|
213
|
+
if (phase === "brainstorm") {
|
|
214
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. The reviewers assessed your artifacts (USER_REQUEST.md, RESEARCH.md, and artifacts/), not a code diff. Read their outputs and update those artifacts as needed.";
|
|
215
|
+
}
|
|
216
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.";
|
|
98
217
|
}
|
|
99
218
|
|
|
100
|
-
export async function enterReviewCycle(
|
|
219
|
+
export async function enterReviewCycle(
|
|
220
|
+
orchestrator: Orchestrator,
|
|
221
|
+
ctx: any,
|
|
222
|
+
kind: "plannotator" | string,
|
|
223
|
+
): Promise<string> {
|
|
101
224
|
if (!orchestrator.active) return "No active task.";
|
|
102
225
|
const pi = orchestrator.pi;
|
|
103
226
|
const pass = orchestrator.active.state.reviewPass + 1;
|
|
104
|
-
orchestrator.active.state.reviewCycle = { kind, step: "spawn_reviewers", pass };
|
|
105
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
106
227
|
|
|
107
228
|
if (kind === "plannotator") {
|
|
229
|
+
orchestrator.active.state.reviewCycle = { kind: "plannotator", step: "spawn_reviewers", pass };
|
|
230
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
108
231
|
const phase = orchestrator.active.state.phase;
|
|
109
232
|
if (phase === "brainstorm") {
|
|
110
233
|
orchestrator.active.state.reviewCycle = null;
|
|
@@ -160,13 +283,14 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
|
|
|
160
283
|
}
|
|
161
284
|
|
|
162
285
|
const phase = orchestrator.active.state.phase;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
286
|
+
const presetName = kind || getDefaultReviewPresetName(orchestrator, phase);
|
|
287
|
+
orchestrator.active.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass };
|
|
288
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
289
|
+
orchestrator.active.state.activeReviewPreset = presetName;
|
|
290
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
291
|
+
|
|
292
|
+
const reviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
293
|
+
const enabledCount = Object.values(reviewers).filter((v) => isEnabled(v)).length;
|
|
170
294
|
if (enabledCount === 0) {
|
|
171
295
|
orchestrator.active.state.reviewCycle = null;
|
|
172
296
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
@@ -174,43 +298,78 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
|
|
|
174
298
|
return `No ${label} reviewers enabled. Choose another option.`;
|
|
175
299
|
}
|
|
176
300
|
|
|
177
|
-
orchestrator.reviewTransitionToken = -1;
|
|
178
301
|
orchestrator.pendingSubagentSpawns = enabledCount;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
302
|
+
const spawnFn = phase === "brainstorm"
|
|
303
|
+
? () => spawnBrainstormReviewers(
|
|
304
|
+
pi,
|
|
305
|
+
orchestrator.cwd,
|
|
306
|
+
orchestrator.active!.dir,
|
|
307
|
+
orchestrator.active!.taskId,
|
|
308
|
+
orchestrator.config,
|
|
309
|
+
pass,
|
|
310
|
+
orchestrator.transitionController.phaseSend,
|
|
311
|
+
reviewers,
|
|
312
|
+
orchestrator.active?.state.repos ?? [],
|
|
313
|
+
)
|
|
314
|
+
: phase === "plan"
|
|
315
|
+
? () => spawnPlanReviewers(
|
|
316
|
+
pi,
|
|
317
|
+
orchestrator.cwd,
|
|
318
|
+
orchestrator.active!.dir,
|
|
319
|
+
orchestrator.active!.taskId,
|
|
320
|
+
orchestrator.config,
|
|
321
|
+
pass,
|
|
322
|
+
orchestrator.transitionController.phaseSend,
|
|
323
|
+
reviewers,
|
|
324
|
+
orchestrator.active?.state.repos ?? [],
|
|
325
|
+
)
|
|
326
|
+
: () => spawnCodeReviewers(
|
|
327
|
+
pi,
|
|
328
|
+
orchestrator.cwd,
|
|
329
|
+
orchestrator.active!.dir,
|
|
330
|
+
orchestrator.active!.taskId,
|
|
331
|
+
orchestrator.config,
|
|
332
|
+
pass,
|
|
333
|
+
phase,
|
|
334
|
+
orchestrator.transitionController.phaseSend,
|
|
335
|
+
reviewers,
|
|
336
|
+
orchestrator.active?.state.repos ?? [],
|
|
337
|
+
);
|
|
338
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
339
|
+
kind: "reviewer",
|
|
340
|
+
logScope: "review",
|
|
341
|
+
logMessage: "spawn reviewers failed",
|
|
342
|
+
logExtra: { phase },
|
|
343
|
+
onSettled: (result) => tryCompleteReviewCycle(orchestrator, result?.spawned),
|
|
196
344
|
});
|
|
197
345
|
|
|
198
346
|
orchestrator.active.state.reviewCycle.step = "await_reviewers";
|
|
199
347
|
orchestrator.active.state.step = "await_reviewers";
|
|
200
348
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
201
|
-
return `Started review cycle pass ${pass} (${
|
|
349
|
+
return `Started review cycle pass ${pass} (auto, preset: ${presetName}). Awaiting reviewers.`;
|
|
202
350
|
}
|
|
203
351
|
|
|
204
352
|
export async function stopTask(orchestrator: Orchestrator): Promise<string> {
|
|
205
353
|
if (!orchestrator.active) return "No active task.";
|
|
206
354
|
orchestrator.abortAllSubagents();
|
|
207
|
-
orchestrator.active.state.
|
|
355
|
+
orchestrator.active.state.reviewCycle = null;
|
|
208
356
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
209
357
|
const desc = orchestrator.active.description;
|
|
358
|
+
const type = orchestrator.active.type;
|
|
210
359
|
await orchestrator.cleanupActive();
|
|
211
360
|
const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
|
|
212
361
|
taskStore?.clearAll?.();
|
|
213
|
-
|
|
362
|
+
|
|
363
|
+
// Route the stop/pause compaction through the controller as a "done" target.
|
|
364
|
+
// The controller supplies the summary to session_before_compact and its
|
|
365
|
+
// awaitable resolves at every terminus (session_compact, no-op skip,
|
|
366
|
+
// already-idle), so this await never hangs and never resolves early.
|
|
367
|
+
await orchestrator.transitionController.requestTransition({
|
|
368
|
+
kind: "done",
|
|
369
|
+
summary: `Task "${desc}" (${type}) stopped/paused.`,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
|
|
214
373
|
}
|
|
215
374
|
|
|
216
375
|
export function finalizeReviewCycle(task: ActiveTask): void {
|
|
@@ -218,19 +377,184 @@ export function finalizeReviewCycle(task: ActiveTask): void {
|
|
|
218
377
|
const kind = task.state.reviewCycle.kind;
|
|
219
378
|
task.state.reviewPass = task.state.reviewCycle.pass;
|
|
220
379
|
task.reviewPass = task.state.reviewPass;
|
|
221
|
-
|
|
222
|
-
task.state.reviewPassByKind[kind] = (task.state.reviewPassByKind[kind] ?? 0) + 1;
|
|
380
|
+
incrementReviewPass(task, kind);
|
|
223
381
|
task.state.reviewCycle = null;
|
|
224
382
|
task.state.step = "user_gate";
|
|
225
383
|
saveTask(task.dir, task.state);
|
|
226
384
|
}
|
|
227
385
|
|
|
386
|
+
function incrementReviewPass(task: ActiveTask, kind: string): void {
|
|
387
|
+
if (!task.state.reviewPassByKind) task.state.reviewPassByKind = {};
|
|
388
|
+
const phase = task.state.phase;
|
|
389
|
+
if (!task.state.reviewPassByKind[phase]) task.state.reviewPassByKind[phase] = {};
|
|
390
|
+
task.state.reviewPassByKind[phase][kind] = (task.state.reviewPassByKind[phase][kind] ?? 0) + 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function completedReviewPasses(task: ActiveTask, kind: string): number {
|
|
394
|
+
return task.state.reviewPassByKind?.[task.state.phase]?.[kind] ?? 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
|
|
398
|
+
if (!task.state.reviewCycle) return;
|
|
399
|
+
const kind = task.state.reviewCycle.kind;
|
|
400
|
+
task.state.reviewPass = task.state.reviewCycle.pass;
|
|
401
|
+
task.reviewPass = task.state.reviewPass;
|
|
402
|
+
incrementReviewPass(task, kind);
|
|
403
|
+
task.state.reviewCycle = null;
|
|
404
|
+
if (task.state.phase === "plan") {
|
|
405
|
+
task.state.step = "synthesize";
|
|
406
|
+
} else {
|
|
407
|
+
task.state.step = "llm_work";
|
|
408
|
+
}
|
|
409
|
+
saveTask(task.dir, task.state);
|
|
410
|
+
}
|
|
411
|
+
|
|
228
412
|
function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
413
|
+
registerRepoTool(orchestrator);
|
|
229
414
|
registerPhaseCompleteTool(orchestrator);
|
|
230
415
|
registerCommitTool(orchestrator);
|
|
231
416
|
registerSpecifyReviewsTool(orchestrator);
|
|
232
417
|
}
|
|
233
418
|
|
|
419
|
+
function registerRepoTool(orchestrator: Orchestrator): void {
|
|
420
|
+
const pi = orchestrator.pi;
|
|
421
|
+
|
|
422
|
+
pi.registerTool({
|
|
423
|
+
name: "pp_register_repo",
|
|
424
|
+
label: "pi-pi",
|
|
425
|
+
description:
|
|
426
|
+
"Register a git repository you're working in. Call this for every repo " +
|
|
427
|
+
"including the root directory at the start of each task. Pass the base " +
|
|
428
|
+
"branch — the branch this work will be merged into (e.g. origin/main, origin/develop).",
|
|
429
|
+
parameters: Type.Object({
|
|
430
|
+
path: Type.String({ description: "Absolute path to the git repository (or any path inside it)" }),
|
|
431
|
+
baseBranch: Type.Optional(Type.String({ description: "Base branch for this repo (e.g. origin/main)" })),
|
|
432
|
+
}),
|
|
433
|
+
async execute(_toolCallId, params: any) {
|
|
434
|
+
if (!orchestrator.active) {
|
|
435
|
+
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
436
|
+
}
|
|
437
|
+
getLogger().debug({ s: "tool", tool: "pp_register_repo", path: params.path, baseBranch: params.baseBranch }, "register repo called");
|
|
438
|
+
|
|
439
|
+
const pathInput = typeof params.path === "string" ? params.path.trim() : "";
|
|
440
|
+
if (!pathInput) {
|
|
441
|
+
return { content: [{ type: "text" as const, text: "Missing path." }], isError: true as const, details: {} };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const normalizedInputPath = normalizeRepoPath(pathInput);
|
|
445
|
+
let gitCwd = normalizedInputPath;
|
|
446
|
+
try {
|
|
447
|
+
gitCwd = statSync(normalizedInputPath).isDirectory() ? normalizedInputPath : dirname(normalizedInputPath);
|
|
448
|
+
} catch {
|
|
449
|
+
gitCwd = dirname(normalizedInputPath);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let gitRoot = "";
|
|
453
|
+
try {
|
|
454
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: gitCwd, timeout: 5000 });
|
|
455
|
+
if (result.code !== 0) {
|
|
456
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
457
|
+
}
|
|
458
|
+
gitRoot = result.stdout.trim();
|
|
459
|
+
} catch {
|
|
460
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!gitRoot) {
|
|
464
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const normalizedRepo = normalizeRepoPath(gitRoot);
|
|
468
|
+
const normalizedRoot = normalizeRepoPath(orchestrator.cwd);
|
|
469
|
+
const hadRepos = Array.isArray(orchestrator.active.state.repos);
|
|
470
|
+
const repos = orchestrator.active.state.repos ?? [{ path: normalizedRoot, isRoot: true }];
|
|
471
|
+
const baseBranch = typeof params.baseBranch === "string" && params.baseBranch.trim().length > 0
|
|
472
|
+
? params.baseBranch.trim()
|
|
473
|
+
: undefined;
|
|
474
|
+
const isRoot = normalizedRepo === normalizedRoot;
|
|
475
|
+
|
|
476
|
+
let added = false;
|
|
477
|
+
let changed = !hadRepos;
|
|
478
|
+
|
|
479
|
+
if (isRoot) {
|
|
480
|
+
const existingRootIdx = repos.findIndex((repo) => repo.isRoot);
|
|
481
|
+
const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
|
|
482
|
+
|
|
483
|
+
if (existingRootIdx >= 0) {
|
|
484
|
+
const existingRoot = repos[existingRootIdx];
|
|
485
|
+
if (existingRoot.path !== normalizedRepo) {
|
|
486
|
+
existingRoot.path = normalizedRepo;
|
|
487
|
+
changed = true;
|
|
488
|
+
}
|
|
489
|
+
if (!existingRoot.isRoot) {
|
|
490
|
+
existingRoot.isRoot = true;
|
|
491
|
+
changed = true;
|
|
492
|
+
}
|
|
493
|
+
if (baseBranch && existingRoot.baseBranch !== baseBranch) {
|
|
494
|
+
existingRoot.baseBranch = baseBranch;
|
|
495
|
+
changed = true;
|
|
496
|
+
}
|
|
497
|
+
if (existingByPathIdx >= 0 && existingByPathIdx !== existingRootIdx) {
|
|
498
|
+
repos.splice(existingByPathIdx, 1);
|
|
499
|
+
changed = true;
|
|
500
|
+
}
|
|
501
|
+
} else if (existingByPathIdx >= 0) {
|
|
502
|
+
const existing = repos[existingByPathIdx];
|
|
503
|
+
if (!existing.isRoot) {
|
|
504
|
+
existing.isRoot = true;
|
|
505
|
+
changed = true;
|
|
506
|
+
}
|
|
507
|
+
if (baseBranch && existing.baseBranch !== baseBranch) {
|
|
508
|
+
existing.baseBranch = baseBranch;
|
|
509
|
+
changed = true;
|
|
510
|
+
}
|
|
511
|
+
} else {
|
|
512
|
+
repos.push({ path: normalizedRepo, isRoot: true, ...(baseBranch ? { baseBranch } : {}) });
|
|
513
|
+
added = true;
|
|
514
|
+
changed = true;
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
|
|
518
|
+
if (existingByPathIdx >= 0) {
|
|
519
|
+
const existing = repos[existingByPathIdx];
|
|
520
|
+
if (baseBranch && existing.baseBranch !== baseBranch) {
|
|
521
|
+
existing.baseBranch = baseBranch;
|
|
522
|
+
changed = true;
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
repos.push({ path: normalizedRepo, isRoot: false, ...(baseBranch ? { baseBranch } : {}) });
|
|
526
|
+
added = true;
|
|
527
|
+
changed = true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
orchestrator.active.state.repos = repos;
|
|
532
|
+
if (changed) {
|
|
533
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (added) {
|
|
537
|
+
unregisterAgentDefinitions(orchestrator.pi);
|
|
538
|
+
orchestrator.registerAgents();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const registered = repos.find((repo) => repo.path === normalizedRepo) ?? {
|
|
542
|
+
path: normalizedRepo,
|
|
543
|
+
isRoot,
|
|
544
|
+
...(baseBranch ? { baseBranch } : {}),
|
|
545
|
+
};
|
|
546
|
+
const rootLabel = registered.isRoot ? " (root)" : "";
|
|
547
|
+
const baseLabel = registered.baseBranch ? `, base: ${registered.baseBranch}` : "";
|
|
548
|
+
const action = added ? "Registered" : changed ? "Updated" : "Already registered";
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
content: [{ type: "text" as const, text: `${action} repository: ${registered.path}${rootLabel}${baseLabel}` }],
|
|
552
|
+
details: {},
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
234
558
|
function openCodeReviewDirect(
|
|
235
559
|
pi: ExtensionAPI,
|
|
236
560
|
payload: Record<string, unknown>,
|
|
@@ -260,7 +584,8 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
260
584
|
description:
|
|
261
585
|
"Specify which repositories and commit ranges to open in Plannotator for code review. " +
|
|
262
586
|
"Called when the user requests a Plannotator code review. " +
|
|
263
|
-
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete."
|
|
587
|
+
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
|
|
588
|
+
"Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
|
|
264
589
|
parameters: Type.Object({
|
|
265
590
|
reviews: Type.Array(Type.Object({
|
|
266
591
|
cwd: Type.String({ description: "Absolute path to the git repository" }),
|
|
@@ -268,6 +593,9 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
268
593
|
})),
|
|
269
594
|
}),
|
|
270
595
|
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
|
|
596
|
+
if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
|
|
597
|
+
return { content: [{ type: "text" as const, text: "Plannotator review is not available in autonomous mode. Continue with automated reviews via pp_phase_complete." }], isError: true as const, details: {} };
|
|
598
|
+
}
|
|
271
599
|
if (!params.reviews || params.reviews.length === 0) {
|
|
272
600
|
return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
|
|
273
601
|
}
|
|
@@ -276,12 +604,89 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
276
604
|
let hasNeedsChanges = false;
|
|
277
605
|
for (const review of params.reviews) {
|
|
278
606
|
ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
|
|
279
|
-
const
|
|
607
|
+
const range = String(review.range ?? "").trim();
|
|
608
|
+
let compareBase = range;
|
|
609
|
+
let compareTarget = "HEAD";
|
|
610
|
+
const rangeSeparatorIdx = range.indexOf("..");
|
|
611
|
+
if (rangeSeparatorIdx >= 0) {
|
|
612
|
+
compareBase = range.slice(0, rangeSeparatorIdx).trim();
|
|
613
|
+
compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!compareBase) {
|
|
617
|
+
results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let reviewCwd = review.cwd;
|
|
622
|
+
let tempWorktreePath: string | null = null;
|
|
623
|
+
let tempWorktreeParent: string | null = null;
|
|
624
|
+
let setupError: string | null = null;
|
|
625
|
+
|
|
626
|
+
if (compareTarget !== "HEAD") {
|
|
627
|
+
tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
|
|
628
|
+
tempWorktreePath = join(tempWorktreeParent, "checkout");
|
|
629
|
+
try {
|
|
630
|
+
const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
|
|
631
|
+
cwd: review.cwd,
|
|
632
|
+
timeout: 20000,
|
|
633
|
+
});
|
|
634
|
+
if (addResult.code !== 0) {
|
|
635
|
+
setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
|
|
636
|
+
} else {
|
|
637
|
+
reviewCwd = tempWorktreePath;
|
|
638
|
+
}
|
|
639
|
+
} catch (error: any) {
|
|
640
|
+
setupError = error?.message ?? String(error);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (setupError) {
|
|
645
|
+
results.push(`${review.cwd} (${review.range}): ${setupError}`);
|
|
646
|
+
if (tempWorktreePath) {
|
|
647
|
+
try {
|
|
648
|
+
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
649
|
+
cwd: review.cwd,
|
|
650
|
+
timeout: 20000,
|
|
651
|
+
});
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
if (tempWorktreePath) {
|
|
655
|
+
try {
|
|
656
|
+
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
657
|
+
} catch {}
|
|
658
|
+
}
|
|
659
|
+
if (tempWorktreeParent) {
|
|
660
|
+
try {
|
|
661
|
+
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
662
|
+
} catch {}
|
|
663
|
+
}
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
|
|
280
667
|
const result = await openCodeReviewDirect(pi, {
|
|
281
|
-
cwd:
|
|
668
|
+
cwd: reviewCwd,
|
|
282
669
|
diffType: "branch",
|
|
283
|
-
defaultBranch:
|
|
670
|
+
defaultBranch: compareBase,
|
|
284
671
|
});
|
|
672
|
+
|
|
673
|
+
if (tempWorktreePath) {
|
|
674
|
+
try {
|
|
675
|
+
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
676
|
+
cwd: review.cwd,
|
|
677
|
+
timeout: 20000,
|
|
678
|
+
});
|
|
679
|
+
} catch {}
|
|
680
|
+
try {
|
|
681
|
+
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
682
|
+
} catch {}
|
|
683
|
+
if (tempWorktreeParent) {
|
|
684
|
+
try {
|
|
685
|
+
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
686
|
+
} catch {}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
285
690
|
if ("error" in result) {
|
|
286
691
|
results.push(`${review.cwd} (${review.range}): ${result.error}`);
|
|
287
692
|
} else {
|
|
@@ -310,9 +715,12 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
310
715
|
try {
|
|
311
716
|
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
312
717
|
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
718
|
+
// A transition may have started while the menu was open. The controller
|
|
719
|
+
// is the source of truth; abort the agent's pending turn so it doesn't
|
|
720
|
+
// race the transition. (Interactive-UX abort — stays local, not routed.)
|
|
721
|
+
if (!orchestrator.transitionController.isRunning()) {
|
|
722
|
+
ctx.abort?.();
|
|
723
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
316
724
|
}
|
|
317
725
|
if (!text) {
|
|
318
726
|
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
@@ -327,7 +735,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
327
735
|
|
|
328
736
|
function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
|
|
329
737
|
if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
|
|
330
|
-
if (phase === "plan") return loadPlanReviewOutputs(taskDir);
|
|
738
|
+
if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
|
|
331
739
|
return loadCodeReviewOutputs(taskDir, pass);
|
|
332
740
|
}
|
|
333
741
|
|
|
@@ -340,37 +748,65 @@ function registerCommitTool(orchestrator: Orchestrator): void {
|
|
|
340
748
|
description:
|
|
341
749
|
"Commit modified files with a descriptive message. Call after completing a logical " +
|
|
342
750
|
"unit of work (e.g. implementing one plan item, fixing a bug, adding a test). " +
|
|
343
|
-
"The message should describe WHAT changed and WHY, not list files."
|
|
751
|
+
"The message should describe WHAT changed and WHY, not list files. " +
|
|
752
|
+
"Prefix the message with a conventional-commit type (fix:, feat:, or chore:) " +
|
|
753
|
+
"unless the user asked for a different commit style.",
|
|
344
754
|
parameters: Type.Object({
|
|
345
755
|
message: Type.String({ description: "Commit message describing the change (max 72 chars for first line)" }),
|
|
756
|
+
repo: Type.Optional(Type.String({ description: "Absolute path to the repo to commit in. Defaults to root." })),
|
|
346
757
|
}),
|
|
347
758
|
async execute(_toolCallId, params: any) {
|
|
348
759
|
if (!orchestrator.active) {
|
|
349
760
|
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
350
761
|
}
|
|
351
|
-
if (!orchestrator.config.autoCommit) {
|
|
762
|
+
if (!orchestrator.config.general.autoCommit) {
|
|
352
763
|
return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
|
|
353
764
|
}
|
|
765
|
+
|
|
766
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
767
|
+
const rootRepo = findRootRepo(repos);
|
|
768
|
+
const defaultRepoPath = rootRepo?.path ?? orchestrator.cwd;
|
|
769
|
+
let commitRepoPath = defaultRepoPath;
|
|
770
|
+
if (typeof params.repo === "string" && params.repo.trim().length > 0) {
|
|
771
|
+
const normalized = normalizeRepoPath(params.repo);
|
|
772
|
+
const registered = repos.find((repo) => repo.path === normalized);
|
|
773
|
+
if (!registered) {
|
|
774
|
+
return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repo}` }], isError: true as const, details: {} };
|
|
775
|
+
}
|
|
776
|
+
commitRepoPath = registered.path;
|
|
777
|
+
}
|
|
778
|
+
|
|
354
779
|
const files: string[] = [];
|
|
355
780
|
try {
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
781
|
+
const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: commitRepoPath, timeout: 5000 });
|
|
782
|
+
if (statusResult.code === 0 && statusResult.stdout.trim()) {
|
|
783
|
+
for (const rawLine of statusResult.stdout.split("\n")) {
|
|
784
|
+
const line = rawLine.trimEnd();
|
|
785
|
+
if (!line) continue;
|
|
786
|
+
const pathPart = line.slice(3);
|
|
787
|
+
if (!pathPart) continue;
|
|
788
|
+
const finalPath = pathPart.includes(" -> ") ? pathPart.split(" -> ").at(-1)! : pathPart;
|
|
789
|
+
if (!files.includes(finalPath)) {
|
|
790
|
+
files.push(finalPath);
|
|
791
|
+
}
|
|
364
792
|
}
|
|
365
793
|
}
|
|
366
794
|
} catch {}
|
|
367
795
|
if (files.length === 0) {
|
|
368
796
|
return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
|
|
369
797
|
}
|
|
370
|
-
const result = autoCommit(files, params.message,
|
|
798
|
+
const result = autoCommit(files, params.message, commitRepoPath);
|
|
371
799
|
if (result.ok) {
|
|
372
|
-
orchestrator.active.modifiedFiles.
|
|
373
|
-
|
|
800
|
+
const remaining = [...orchestrator.active.modifiedFiles].filter((file) => {
|
|
801
|
+
const absoluteFile = resolve(orchestrator.cwd, file);
|
|
802
|
+
const repo = resolveRepoForFile(repos, absoluteFile);
|
|
803
|
+
return repo?.path !== commitRepoPath;
|
|
804
|
+
});
|
|
805
|
+
orchestrator.active.modifiedFiles = new Set(remaining);
|
|
806
|
+
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
807
|
+
const committed = new Set(orchestrator.active.state.committedFiles ?? []);
|
|
808
|
+
for (const file of files) committed.add(file);
|
|
809
|
+
orchestrator.active.state.committedFiles = [...committed];
|
|
374
810
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
375
811
|
orchestrator.commitReminderSent = false;
|
|
376
812
|
return { content: [{ type: "text" as const, text: `Committed ${files.length} file(s): ${result.commitHash ?? "ok"}` }], details: {} };
|
|
@@ -404,13 +840,87 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
404
840
|
const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
|
|
405
841
|
return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
|
|
406
842
|
}
|
|
843
|
+
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
844
|
+
if (effectiveMode === "autonomous") {
|
|
845
|
+
const phase = orchestrator.active.state.phase;
|
|
846
|
+
let justFinalizedReviewCycle = false;
|
|
847
|
+
if (orchestrator.active.state.reviewCycle?.step === "apply_feedback") {
|
|
848
|
+
const completedRound = orchestrator.active.state.reviewCycle.pass;
|
|
849
|
+
const completedPreset = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
850
|
+
const enabledReviewerCount = Object.values(resolveReviewers(orchestrator, phase, completedPreset)).filter((v) => isEnabled(v)).length;
|
|
851
|
+
finalizeReviewCycleAutonomous(orchestrator.active);
|
|
852
|
+
justFinalizedReviewCycle = true;
|
|
853
|
+
if (reviewPassUnanimousApprove(orchestrator.active.dir, phase, completedRound, enabledReviewerCount)) {
|
|
854
|
+
orchestrator.active.state.reviewApprovedClean = true;
|
|
855
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const phaseConfig = orchestrator.active.state.autonomousConfig?.phases?.[phase];
|
|
859
|
+
const reviewPreset = phaseConfig?.reviewPreset;
|
|
860
|
+
const maxReviewPasses = phaseConfig?.maxReviewPasses ?? 0;
|
|
861
|
+
const completedAutoPasses = orchestrator.active.state.reviewPassByKind?.[phase]?.auto ?? 0;
|
|
862
|
+
|
|
863
|
+
if (
|
|
864
|
+
justFinalizedReviewCycle &&
|
|
865
|
+
!orchestrator.active.state.reviewApprovedClean &&
|
|
866
|
+
reviewPreset &&
|
|
867
|
+
maxReviewPasses > 0 &&
|
|
868
|
+
completedAutoPasses < maxReviewPasses
|
|
869
|
+
) {
|
|
870
|
+
return {
|
|
871
|
+
content: [{ type: "text" as const, text: `The review pass found changes to make. Apply the reviewers' required changes, then call pp_phase_complete again to re-review (pass ${completedAutoPasses + 1}/${maxReviewPasses >= 999 ? "∞" : maxReviewPasses}). Do NOT wait for the user and do NOT advance the phase.` }],
|
|
872
|
+
details: {},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (
|
|
877
|
+
!justFinalizedReviewCycle &&
|
|
878
|
+
!orchestrator.active.state.reviewApprovedClean &&
|
|
879
|
+
reviewPreset &&
|
|
880
|
+
maxReviewPasses > 0 &&
|
|
881
|
+
completedAutoPasses < maxReviewPasses
|
|
882
|
+
) {
|
|
883
|
+
const exitCheck = validateExitCriteria(orchestrator.active.dir, orchestrator.active.type, phase);
|
|
884
|
+
if (!exitCheck.ok) {
|
|
885
|
+
return {
|
|
886
|
+
content: [{ type: "text" as const, text: `Cannot start review yet: ${exitCheck.reason}\n\nFix this and call pp_phase_complete again. Do NOT wait for the user.` }],
|
|
887
|
+
details: {},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const reviewText = await enterReviewCycle(orchestrator, ctx, reviewPreset);
|
|
891
|
+
if (orchestrator.active?.state.step === "await_reviewers") {
|
|
892
|
+
return {
|
|
893
|
+
content: [{ type: "text" as const, text: `Reviews are running (${reviewPreset}, pass ${completedAutoPasses + 1}/${maxReviewPasses >= 999 ? "∞" : maxReviewPasses}). You will be notified automatically when the reviewer outputs are ready — then read them and proceed. Do NOT wait for the user and do NOT stop.` }],
|
|
894
|
+
details: {},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
if (!reviewText.includes("No") || !reviewText.includes("reviewers enabled")) {
|
|
898
|
+
return { content: [{ type: "text" as const, text: reviewText }], details: {} };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
|
|
903
|
+
const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
|
|
904
|
+
if (!result.ok) {
|
|
905
|
+
return { content: [{ type: "text" as const, text: `Transition blocked: ${result.error}` }], details: {} };
|
|
906
|
+
}
|
|
907
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
908
|
+
}
|
|
407
909
|
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
408
910
|
try {
|
|
409
911
|
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
410
912
|
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
913
|
+
// A transition or await may have started while the menu was open. The
|
|
914
|
+
// controller is the source of truth; abort the pending turn so it can't
|
|
915
|
+
// race. (Interactive-UX abort — stays local, not routed.)
|
|
916
|
+
const curStep = orchestrator.active?.state.step;
|
|
917
|
+
if (curStep === "await_planners" || curStep === "await_reviewers") {
|
|
918
|
+
ctx.abort?.();
|
|
919
|
+
return { content: [{ type: "text" as const, text: `Waiting for ${curStep === "await_planners" ? "planners" : "reviewers"} to complete. Do NOT proceed until notified.` }], details: {} };
|
|
920
|
+
}
|
|
921
|
+
if (!orchestrator.transitionController.isRunning()) {
|
|
922
|
+
ctx.abort?.();
|
|
923
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
414
924
|
}
|
|
415
925
|
if (!text) {
|
|
416
926
|
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
@@ -423,9 +933,89 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
423
933
|
});
|
|
424
934
|
}
|
|
425
935
|
|
|
936
|
+
function registerMainTraceHooks(orchestrator: Orchestrator): void {
|
|
937
|
+
const pi = orchestrator.pi;
|
|
938
|
+
// These hooks are registered only on the root orchestrator session
|
|
939
|
+
// (registerEventHandlers is never called from the subagent branch in index.ts),
|
|
940
|
+
// so they only ever receive root-session events — no SUBAGENT_SESSION_KEY gate needed.
|
|
941
|
+
|
|
942
|
+
pi.on("before_agent_start", async (event) => {
|
|
943
|
+
getTracer()?.traceMain("before_agent_start", {
|
|
944
|
+
prompt: event.prompt,
|
|
945
|
+
images: event.images,
|
|
946
|
+
systemPrompt: event.systemPrompt,
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
pi.on("agent_start", async () => {
|
|
950
|
+
getTracer()?.traceMain("agent_start", {});
|
|
951
|
+
});
|
|
952
|
+
pi.on("agent_end", async (event) => {
|
|
953
|
+
getTracer()?.traceMain("agent_end", { messages: event.messages });
|
|
954
|
+
});
|
|
955
|
+
pi.on("turn_start", async (event) => {
|
|
956
|
+
const tracer = getTracer();
|
|
957
|
+
if (tracer) tracer.turnIndex = event.turnIndex;
|
|
958
|
+
tracer?.traceMain("turn_start", { turnIndex: event.turnIndex, timestamp: event.timestamp });
|
|
959
|
+
});
|
|
960
|
+
pi.on("turn_end", async (event) => {
|
|
961
|
+
const tracer = getTracer();
|
|
962
|
+
if (tracer) tracer.turnIndex = event.turnIndex;
|
|
963
|
+
tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
|
|
964
|
+
});
|
|
965
|
+
pi.on("message_start", async (event) => {
|
|
966
|
+
const tracer = getTracer();
|
|
967
|
+
tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
|
|
968
|
+
});
|
|
969
|
+
pi.on("message_update", async (event) => {
|
|
970
|
+
const tracer = getTracer();
|
|
971
|
+
tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
|
|
972
|
+
});
|
|
973
|
+
pi.on("message_end", async (event) => {
|
|
974
|
+
const tracer = getTracer();
|
|
975
|
+
tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
|
|
976
|
+
});
|
|
977
|
+
pi.on("tool_execution_start", async (event) => {
|
|
978
|
+
const tracer = getTracer();
|
|
979
|
+
tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
|
|
980
|
+
});
|
|
981
|
+
pi.on("tool_execution_update", async (event) => {
|
|
982
|
+
const tracer = getTracer();
|
|
983
|
+
tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
|
|
984
|
+
});
|
|
985
|
+
pi.on("tool_execution_end", async (event) => {
|
|
986
|
+
const tracer = getTracer();
|
|
987
|
+
tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
426
991
|
export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
427
992
|
const pi = orchestrator.pi;
|
|
428
993
|
|
|
994
|
+
registerMainTraceHooks(orchestrator);
|
|
995
|
+
|
|
996
|
+
// TransitionController drivers. These are the reliable idle/completion signals:
|
|
997
|
+
// - agent_end: the main loop went idle. If a transition is pending, this is
|
|
998
|
+
// when the controller fires compaction.
|
|
999
|
+
// - session_compact: compaction finished. The controller resumes (injects
|
|
1000
|
+
// context/artifacts + sends the next instruction).
|
|
1001
|
+
// The SDK supports multiple handlers per event, so these coexist with the
|
|
1002
|
+
// trace-only hooks registered above. registerEventHandlers is never called on
|
|
1003
|
+
// the subagent branch, so these only ever see root-session events.
|
|
1004
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1005
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1006
|
+
orchestrator.transitionController.onAgentEnd();
|
|
1007
|
+
});
|
|
1008
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
1009
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1010
|
+
orchestrator.transitionController.onSessionCompact();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Expose the event-driven planner-completion check so initial plan-entry
|
|
1014
|
+
// spawns (in command-handlers / orchestrator) can wire it as their onSettled
|
|
1015
|
+
// safety net (the deleted poller's former role). checkPlannerCompletion is a
|
|
1016
|
+
// hoisted function declaration below.
|
|
1017
|
+
orchestrator.checkPlannerCompletion = () => checkPlannerCompletion();
|
|
1018
|
+
|
|
429
1019
|
function getUsageTracker(): UsageTracker | undefined {
|
|
430
1020
|
return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
|
|
431
1021
|
}
|
|
@@ -445,6 +1035,25 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
445
1035
|
if (event === "first_turn" && lifecycle.firstTurnAt == null) lifecycle.firstTurnAt = now;
|
|
446
1036
|
orchestrator.agentLifecycle.set(data.id, lifecycle);
|
|
447
1037
|
|
|
1038
|
+
getTracer()?.traceMain("subagent_lifecycle", {
|
|
1039
|
+
event,
|
|
1040
|
+
subagentId: data.id,
|
|
1041
|
+
type: data.type ?? lifecycle.type,
|
|
1042
|
+
description: data.description ?? lifecycle.description,
|
|
1043
|
+
parentToolCallId: data.toolCallId,
|
|
1044
|
+
phase: lifecycle.phase,
|
|
1045
|
+
step: lifecycle.step,
|
|
1046
|
+
toolName: data.toolName,
|
|
1047
|
+
turnCount: data.turnCount,
|
|
1048
|
+
tokens: data.tokens,
|
|
1049
|
+
durationMs: data.durationMs,
|
|
1050
|
+
toolUses: data.toolUses,
|
|
1051
|
+
modelId: data.modelId,
|
|
1052
|
+
status: data.status,
|
|
1053
|
+
error: data.error,
|
|
1054
|
+
result: data.result,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
448
1057
|
const ageMs = lifecycle.createdAt == null ? 0 : now - lifecycle.createdAt;
|
|
449
1058
|
const startedDeltaMs = lifecycle.startedAt == null || lifecycle.createdAt == null ? undefined : lifecycle.startedAt - lifecycle.createdAt;
|
|
450
1059
|
const firstToolDeltaMs = lifecycle.firstToolAt == null || lifecycle.createdAt == null ? undefined : lifecycle.firstToolAt - lifecycle.createdAt;
|
|
@@ -452,8 +1061,8 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
452
1061
|
const pending = orchestrator.pendingSubagentSpawns;
|
|
453
1062
|
const running = orchestrator.spawnedAgentIds.size;
|
|
454
1063
|
const desc = data.description || lifecycle.description || data.type || lifecycle.type || data.id;
|
|
455
|
-
|
|
456
|
-
|
|
1064
|
+
getLogger().debug({
|
|
1065
|
+
s: "subagent",
|
|
457
1066
|
id: data.id,
|
|
458
1067
|
event,
|
|
459
1068
|
description: desc,
|
|
@@ -468,7 +1077,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
468
1077
|
createdToFirstTurnMs: firstTurnDeltaMs ?? null,
|
|
469
1078
|
toolName: data.toolName ?? null,
|
|
470
1079
|
turnCount: data.turnCount ?? null,
|
|
471
|
-
});
|
|
1080
|
+
}, "subagent lifecycle event");
|
|
472
1081
|
}
|
|
473
1082
|
|
|
474
1083
|
function startStaleAgentWatchdog(): void {
|
|
@@ -481,7 +1090,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
481
1090
|
}
|
|
482
1091
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
483
1092
|
const now = Date.now();
|
|
484
|
-
const staleMs = orchestrator.config.
|
|
1093
|
+
const staleMs = orchestrator.config.performance.internals.subagentStale;
|
|
485
1094
|
for (const [id, spawnTime] of orchestrator.agentSpawnTimes) {
|
|
486
1095
|
const record = mgr?.getRecord?.(id);
|
|
487
1096
|
if (record?.status === "running" || record?.status === "queued") continue;
|
|
@@ -493,13 +1102,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
493
1102
|
orchestrator.agentSpawnTimes.delete(id);
|
|
494
1103
|
orchestrator.agentDescriptions.delete(id);
|
|
495
1104
|
orchestrator.agentLifecycle.delete(id);
|
|
496
|
-
|
|
1105
|
+
orchestrator.transitionController.sendCustom(
|
|
497
1106
|
{
|
|
498
1107
|
customType: "pp-agent-stale",
|
|
499
1108
|
content: `Aborted stale agent "${desc}" — no completion after ${Math.round(staleMs / 1000)}s.`,
|
|
500
1109
|
display: true,
|
|
501
1110
|
},
|
|
502
|
-
|
|
1111
|
+
"context",
|
|
503
1112
|
);
|
|
504
1113
|
}
|
|
505
1114
|
}
|
|
@@ -539,13 +1148,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
539
1148
|
trackSubagentEvent(data, "first_turn");
|
|
540
1149
|
});
|
|
541
1150
|
|
|
1151
|
+
function markAllAgentsConsumed(): void {
|
|
1152
|
+
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
1153
|
+
if (!mgr?.getRecord) return;
|
|
1154
|
+
for (const id of orchestrator.agentDescriptions.keys()) {
|
|
1155
|
+
const record = mgr.getRecord(id);
|
|
1156
|
+
if (record) record.resultConsumed = true;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
542
1160
|
function checkPlannerCompletion(): void {
|
|
543
1161
|
if (
|
|
544
1162
|
!orchestrator.active ||
|
|
545
1163
|
orchestrator.active.state.phase !== "plan" ||
|
|
546
1164
|
orchestrator.active.state.step !== "await_planners" ||
|
|
547
1165
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
548
|
-
orchestrator.pendingSubagentSpawns > 0
|
|
1166
|
+
orchestrator.pendingSubagentSpawns > 0 ||
|
|
1167
|
+
orchestrator.transitionController.isTransitioning()
|
|
549
1168
|
) return;
|
|
550
1169
|
|
|
551
1170
|
const plansDir = join(orchestrator.active.dir, "plans");
|
|
@@ -553,8 +1172,75 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
553
1172
|
readdirSync(plansDir).some((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
|
|
554
1173
|
|
|
555
1174
|
const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
|
|
1175
|
+
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1176
|
+
if (effectiveMode === "autonomous" && failedPlannerVariants.length > 0) {
|
|
1177
|
+
const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
|
|
1178
|
+
if (!alreadyRetried) {
|
|
1179
|
+
const failedSet = new Set(failedPlannerVariants);
|
|
1180
|
+
const presetName = normalizeStoredPlannerPresetName(orchestrator);
|
|
1181
|
+
const planners = resolvePreset(orchestrator.config, "planners", presetName);
|
|
1182
|
+
const scopedPlanners: typeof planners = {};
|
|
1183
|
+
for (const [name, cfg] of Object.entries(planners)) {
|
|
1184
|
+
if (failedSet.has(name)) scopedPlanners[name] = cfg;
|
|
1185
|
+
}
|
|
1186
|
+
const retryCount = Object.keys(scopedPlanners).length;
|
|
1187
|
+
if (retryCount > 0) {
|
|
1188
|
+
orchestrator.active.state.plannerFailureAutoRetried = true;
|
|
1189
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1190
|
+
orchestrator.failedPlannerVariants = [];
|
|
1191
|
+
orchestrator.pendingSubagentSpawns = retryCount;
|
|
1192
|
+
handleSpawnResult(
|
|
1193
|
+
orchestrator,
|
|
1194
|
+
spawnPlanners(
|
|
1195
|
+
pi,
|
|
1196
|
+
orchestrator.cwd,
|
|
1197
|
+
orchestrator.active!.dir,
|
|
1198
|
+
orchestrator.active!.taskId,
|
|
1199
|
+
orchestrator.config,
|
|
1200
|
+
orchestrator.transitionController.phaseSend,
|
|
1201
|
+
scopedPlanners,
|
|
1202
|
+
orchestrator.active?.state.repos ?? [],
|
|
1203
|
+
),
|
|
1204
|
+
{
|
|
1205
|
+
kind: "planner",
|
|
1206
|
+
logScope: "planner",
|
|
1207
|
+
logMessage: "retry spawnPlanners failed",
|
|
1208
|
+
onSettled: checkPlannerCompletion,
|
|
1209
|
+
},
|
|
1210
|
+
);
|
|
1211
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners once: ${failedPlannerVariants.join(", ")}.`);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
orchestrator.failedPlannerVariants = [];
|
|
1217
|
+
orchestrator.active.state.plannerFailureAutoRetried = false;
|
|
1218
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1219
|
+
if (!hasPlanFiles) {
|
|
1220
|
+
// Custom (sendMessage) followUp does NOT start a turn when the agent is
|
|
1221
|
+
// idle (SDK agent-session.js) — emit the payload as display context and
|
|
1222
|
+
// start the synthesizer turn via safeSendUserMessage (sendUserMessage
|
|
1223
|
+
// always triggers a turn).
|
|
1224
|
+
orchestrator.transitionController.sendCustom(
|
|
1225
|
+
{
|
|
1226
|
+
customType: "pp-planners-error",
|
|
1227
|
+
content: "All planner subagents failed. Continue without planner outputs and synthesize the plan yourself.",
|
|
1228
|
+
display: true,
|
|
1229
|
+
},
|
|
1230
|
+
"context",
|
|
1231
|
+
);
|
|
1232
|
+
orchestrator.safeSendUserMessage("[PI-PI] All planners failed. Synthesize the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
|
|
1233
|
+
} else {
|
|
1234
|
+
orchestrator.safeSendUserMessage("[PI-PI] Some planners failed. Continue with available planner outputs.");
|
|
1235
|
+
}
|
|
1236
|
+
orchestrator.active.state.step = "synthesize";
|
|
1237
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
556
1241
|
if (
|
|
557
1242
|
failedPlannerVariants.length > 0 &&
|
|
1243
|
+
effectiveMode !== "autonomous" &&
|
|
558
1244
|
!orchestrator.plannerFailureDialogPending &&
|
|
559
1245
|
orchestrator.lastCtx
|
|
560
1246
|
) {
|
|
@@ -572,35 +1258,42 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
572
1258
|
|
|
573
1259
|
if (choice === "Retry failed planners") {
|
|
574
1260
|
const failedSet = new Set(failedPlannerVariants);
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
1261
|
+
const presetName = normalizeStoredPlannerPresetName(orchestrator);
|
|
1262
|
+
const planners = resolvePreset(orchestrator.config, "planners", presetName);
|
|
1263
|
+
const scopedPlanners: typeof planners = {};
|
|
1264
|
+
for (const [name, cfg] of Object.entries(planners)) {
|
|
1265
|
+
if (failedSet.has(name)) scopedPlanners[name] = cfg;
|
|
578
1266
|
}
|
|
579
|
-
const retryCount = Object.keys(
|
|
1267
|
+
const retryCount = Object.keys(scopedPlanners).length;
|
|
580
1268
|
if (retryCount > 0) {
|
|
581
|
-
const retryConfig = { ...orchestrator.config, planners };
|
|
582
1269
|
orchestrator.failedPlannerVariants = [];
|
|
583
1270
|
orchestrator.pendingSubagentSpawns = retryCount;
|
|
584
|
-
|
|
585
|
-
orchestrator
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
orchestrator.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1271
|
+
handleSpawnResult(
|
|
1272
|
+
orchestrator,
|
|
1273
|
+
spawnPlanners(
|
|
1274
|
+
pi,
|
|
1275
|
+
orchestrator.cwd,
|
|
1276
|
+
orchestrator.active!.dir,
|
|
1277
|
+
orchestrator.active!.taskId,
|
|
1278
|
+
orchestrator.config,
|
|
1279
|
+
orchestrator.transitionController.phaseSend,
|
|
1280
|
+
scopedPlanners,
|
|
1281
|
+
orchestrator.active?.state.repos ?? [],
|
|
1282
|
+
),
|
|
1283
|
+
{
|
|
1284
|
+
kind: "planner",
|
|
1285
|
+
logScope: "planner",
|
|
1286
|
+
logMessage: "retry spawnPlanners failed",
|
|
1287
|
+
onSettled: checkPlannerCompletion,
|
|
1288
|
+
},
|
|
1289
|
+
);
|
|
1290
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`);
|
|
598
1291
|
return;
|
|
599
1292
|
}
|
|
600
1293
|
}
|
|
601
1294
|
|
|
602
1295
|
if (choice === "Stop task") {
|
|
603
|
-
|
|
1296
|
+
orchestrator.safeSendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`);
|
|
604
1297
|
return;
|
|
605
1298
|
}
|
|
606
1299
|
|
|
@@ -616,23 +1309,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
616
1309
|
}
|
|
617
1310
|
|
|
618
1311
|
if (!hasPlanFiles) {
|
|
619
|
-
|
|
1312
|
+
// Display payload as context; start the synthesizer turn via safeSendUserMessage
|
|
1313
|
+
// (a custom followUp message does not start an idle turn).
|
|
1314
|
+
orchestrator.transitionController.sendCustom(
|
|
620
1315
|
{
|
|
621
1316
|
customType: "pp-planners-error",
|
|
622
1317
|
content: "All planner subagents finished but no plan files were produced. You must create the plan yourself based on USER_REQUEST.md and RESEARCH.md.",
|
|
623
1318
|
display: true,
|
|
624
1319
|
},
|
|
625
|
-
|
|
1320
|
+
"context",
|
|
626
1321
|
);
|
|
627
1322
|
orchestrator.active.state.step = "synthesize";
|
|
628
1323
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1324
|
+
orchestrator.safeSendUserMessage("[PI-PI] No plan files were produced. Create the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
|
|
629
1325
|
return;
|
|
630
1326
|
}
|
|
631
1327
|
|
|
632
1328
|
orchestrator.failedPlannerVariants = [];
|
|
1329
|
+
orchestrator.active.state.plannerFailureAutoRetried = false;
|
|
1330
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1331
|
+
markAllAgentsConsumed();
|
|
633
1332
|
orchestrator.active.state.step = "synthesize";
|
|
634
1333
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
635
|
-
|
|
1334
|
+
orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
|
|
636
1335
|
}
|
|
637
1336
|
|
|
638
1337
|
function checkReviewCycleCompletion(): void {
|
|
@@ -640,12 +1339,96 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
640
1339
|
!orchestrator.active?.state.reviewCycle ||
|
|
641
1340
|
orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
|
|
642
1341
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
643
|
-
orchestrator.pendingSubagentSpawns > 0
|
|
1342
|
+
orchestrator.pendingSubagentSpawns > 0 ||
|
|
1343
|
+
orchestrator.transitionController.isTransitioning()
|
|
644
1344
|
) return;
|
|
645
1345
|
|
|
646
1346
|
const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
|
|
1347
|
+
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1348
|
+
if (effectiveMode === "autonomous" && failedReviewerVariants.length > 0) {
|
|
1349
|
+
const cycle = orchestrator.active.state.reviewCycle;
|
|
1350
|
+
const phase = orchestrator.active.state.phase;
|
|
1351
|
+
const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
|
|
1352
|
+
const alreadyRetried = orchestrator.active.state.reviewerFailureAutoRetried === true;
|
|
1353
|
+
if (!alreadyRetried) {
|
|
1354
|
+
const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
1355
|
+
const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
1356
|
+
const failedSet = new Set(failedReviewerVariants);
|
|
1357
|
+
const scopedReviewers: typeof sourceReviewers = {};
|
|
1358
|
+
for (const [name, cfg] of Object.entries(sourceReviewers)) {
|
|
1359
|
+
if (failedSet.has(name)) scopedReviewers[name] = cfg;
|
|
1360
|
+
}
|
|
1361
|
+
const retryCount = Object.keys(scopedReviewers).length;
|
|
1362
|
+
if (retryCount > 0) {
|
|
1363
|
+
const spawnFn = phase === "brainstorm"
|
|
1364
|
+
? () => spawnBrainstormReviewers(
|
|
1365
|
+
pi,
|
|
1366
|
+
orchestrator.cwd,
|
|
1367
|
+
orchestrator.active!.dir,
|
|
1368
|
+
orchestrator.active!.taskId,
|
|
1369
|
+
orchestrator.config,
|
|
1370
|
+
cycle.pass,
|
|
1371
|
+
orchestrator.transitionController.phaseSend,
|
|
1372
|
+
scopedReviewers,
|
|
1373
|
+
orchestrator.active?.state.repos ?? [],
|
|
1374
|
+
)
|
|
1375
|
+
: phase === "plan"
|
|
1376
|
+
? () => spawnPlanReviewers(
|
|
1377
|
+
pi,
|
|
1378
|
+
orchestrator.cwd,
|
|
1379
|
+
orchestrator.active!.dir,
|
|
1380
|
+
orchestrator.active!.taskId,
|
|
1381
|
+
orchestrator.config,
|
|
1382
|
+
cycle.pass,
|
|
1383
|
+
orchestrator.transitionController.phaseSend,
|
|
1384
|
+
scopedReviewers,
|
|
1385
|
+
orchestrator.active?.state.repos ?? [],
|
|
1386
|
+
)
|
|
1387
|
+
: () => spawnCodeReviewers(
|
|
1388
|
+
pi,
|
|
1389
|
+
orchestrator.cwd,
|
|
1390
|
+
orchestrator.active!.dir,
|
|
1391
|
+
orchestrator.active!.taskId,
|
|
1392
|
+
orchestrator.config,
|
|
1393
|
+
cycle.pass,
|
|
1394
|
+
phase,
|
|
1395
|
+
orchestrator.transitionController.phaseSend,
|
|
1396
|
+
scopedReviewers,
|
|
1397
|
+
orchestrator.active?.state.repos ?? [],
|
|
1398
|
+
);
|
|
1399
|
+
orchestrator.active.state.reviewerFailureAutoRetried = true;
|
|
1400
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1401
|
+
orchestrator.failedReviewerVariants = [];
|
|
1402
|
+
orchestrator.pendingSubagentSpawns = retryCount;
|
|
1403
|
+
cycle.step = "await_reviewers";
|
|
1404
|
+
orchestrator.active.state.step = "await_reviewers";
|
|
1405
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1406
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
1407
|
+
kind: "reviewer",
|
|
1408
|
+
logScope: "review",
|
|
1409
|
+
logMessage: "retry spawn reviewers failed",
|
|
1410
|
+
logExtra: { phase },
|
|
1411
|
+
onSettled: checkReviewCycleCompletion,
|
|
1412
|
+
});
|
|
1413
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers once: ${failedReviewerVariants.join(", ")}.`);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
orchestrator.failedReviewerVariants = [];
|
|
1419
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
1420
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1421
|
+
if (outputs.length === 0) {
|
|
1422
|
+
finalizeReviewCycleAutonomous(orchestrator.active);
|
|
1423
|
+
orchestrator.safeSendUserMessage("[PI-PI] All reviewers failed twice. Continue without reviewer outputs and proceed with best judgment.");
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
orchestrator.safeSendUserMessage("[PI-PI] Some reviewers failed. Continue with available reviewer outputs.");
|
|
1427
|
+
}
|
|
1428
|
+
|
|
647
1429
|
if (
|
|
648
1430
|
failedReviewerVariants.length > 0 &&
|
|
1431
|
+
effectiveMode !== "autonomous" &&
|
|
649
1432
|
!orchestrator.reviewerFailureDialogPending &&
|
|
650
1433
|
orchestrator.lastCtx
|
|
651
1434
|
) {
|
|
@@ -663,13 +1446,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
663
1446
|
const cycle = orchestrator.active?.state.reviewCycle;
|
|
664
1447
|
if (cycle) {
|
|
665
1448
|
const pass = cycle.pass;
|
|
666
|
-
const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
|
|
667
1449
|
const phase = orchestrator.active!.state.phase;
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
: phase === "plan"
|
|
671
|
-
? reviewConfig.planReviewers
|
|
672
|
-
: reviewConfig.codeReviewers;
|
|
1450
|
+
const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
1451
|
+
const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
673
1452
|
const failedSet = new Set(failedReviewerVariants);
|
|
674
1453
|
const scopedReviewers: typeof sourceReviewers = {};
|
|
675
1454
|
for (const [name, cfg] of Object.entries(sourceReviewers)) {
|
|
@@ -677,42 +1456,62 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
677
1456
|
}
|
|
678
1457
|
const retryCount = Object.keys(scopedReviewers).length;
|
|
679
1458
|
if (retryCount > 0) {
|
|
680
|
-
const retryConfig = phase === "brainstorm"
|
|
681
|
-
? { ...reviewConfig, brainstormReviewers: scopedReviewers }
|
|
682
|
-
: phase === "plan"
|
|
683
|
-
? { ...reviewConfig, planReviewers: scopedReviewers }
|
|
684
|
-
: { ...reviewConfig, codeReviewers: scopedReviewers };
|
|
685
1459
|
const spawnFn = phase === "brainstorm"
|
|
686
|
-
? () => spawnBrainstormReviewers(
|
|
1460
|
+
? () => spawnBrainstormReviewers(
|
|
1461
|
+
pi,
|
|
1462
|
+
orchestrator.cwd,
|
|
1463
|
+
orchestrator.active!.dir,
|
|
1464
|
+
orchestrator.active!.taskId,
|
|
1465
|
+
orchestrator.config,
|
|
1466
|
+
pass,
|
|
1467
|
+
orchestrator.transitionController.phaseSend,
|
|
1468
|
+
scopedReviewers,
|
|
1469
|
+
orchestrator.active?.state.repos ?? [],
|
|
1470
|
+
)
|
|
687
1471
|
: phase === "plan"
|
|
688
|
-
? () => spawnPlanReviewers(
|
|
689
|
-
|
|
1472
|
+
? () => spawnPlanReviewers(
|
|
1473
|
+
pi,
|
|
1474
|
+
orchestrator.cwd,
|
|
1475
|
+
orchestrator.active!.dir,
|
|
1476
|
+
orchestrator.active!.taskId,
|
|
1477
|
+
orchestrator.config,
|
|
1478
|
+
pass,
|
|
1479
|
+
orchestrator.transitionController.phaseSend,
|
|
1480
|
+
scopedReviewers,
|
|
1481
|
+
orchestrator.active?.state.repos ?? [],
|
|
1482
|
+
)
|
|
1483
|
+
: () => spawnCodeReviewers(
|
|
1484
|
+
pi,
|
|
1485
|
+
orchestrator.cwd,
|
|
1486
|
+
orchestrator.active!.dir,
|
|
1487
|
+
orchestrator.active!.taskId,
|
|
1488
|
+
orchestrator.config,
|
|
1489
|
+
pass,
|
|
1490
|
+
phase,
|
|
1491
|
+
orchestrator.transitionController.phaseSend,
|
|
1492
|
+
scopedReviewers,
|
|
1493
|
+
orchestrator.active?.state.repos ?? [],
|
|
1494
|
+
);
|
|
690
1495
|
orchestrator.failedReviewerVariants = [];
|
|
691
1496
|
orchestrator.pendingSubagentSpawns = retryCount;
|
|
692
1497
|
cycle.step = "await_reviewers";
|
|
693
1498
|
orchestrator.active!.state.step = "await_reviewers";
|
|
694
1499
|
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
695
|
-
spawnFn()
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
702
|
-
checkReviewCycleCompletion();
|
|
703
|
-
}).catch((err) => {
|
|
704
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
705
|
-
console.error(`[pi-pi] retry spawn reviewers failed (${phase}): ${err.message}`);
|
|
706
|
-
checkReviewCycleCompletion();
|
|
1500
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
1501
|
+
kind: "reviewer",
|
|
1502
|
+
logScope: "review",
|
|
1503
|
+
logMessage: "retry spawn reviewers failed",
|
|
1504
|
+
logExtra: { phase },
|
|
1505
|
+
onSettled: checkReviewCycleCompletion,
|
|
707
1506
|
});
|
|
708
|
-
|
|
1507
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`);
|
|
709
1508
|
return;
|
|
710
1509
|
}
|
|
711
1510
|
}
|
|
712
1511
|
}
|
|
713
1512
|
|
|
714
1513
|
if (choice === "Stop task") {
|
|
715
|
-
|
|
1514
|
+
orchestrator.safeSendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`);
|
|
716
1515
|
return;
|
|
717
1516
|
}
|
|
718
1517
|
|
|
@@ -722,7 +1521,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
722
1521
|
orchestrator.active.state.reviewCycle = null;
|
|
723
1522
|
orchestrator.active.state.step = "user_gate";
|
|
724
1523
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
725
|
-
|
|
1524
|
+
orchestrator.safeSendUserMessage("[PI-PI] Review cycle skipped. Use /pp to choose the next action.");
|
|
726
1525
|
return;
|
|
727
1526
|
}
|
|
728
1527
|
|
|
@@ -735,6 +1534,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
735
1534
|
return;
|
|
736
1535
|
}
|
|
737
1536
|
|
|
1537
|
+
if (orchestrator.active.state.reviewerFailureAutoRetried) {
|
|
1538
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
1539
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1540
|
+
}
|
|
1541
|
+
markAllAgentsConsumed();
|
|
738
1542
|
tryCompleteReviewCycle(orchestrator);
|
|
739
1543
|
}
|
|
740
1544
|
|
|
@@ -763,13 +1567,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
763
1567
|
const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
|
|
764
1568
|
const stats = [duration, tokens].filter(Boolean).join(", ");
|
|
765
1569
|
|
|
766
|
-
|
|
1570
|
+
orchestrator.transitionController.sendCustom(
|
|
767
1571
|
{
|
|
768
1572
|
customType: "pp-subagent-result",
|
|
769
1573
|
content: `${desc} completed${stats ? ` (${stats})` : ""}. Use get_subagent_result to read the output.`,
|
|
770
1574
|
display: false,
|
|
771
1575
|
},
|
|
772
|
-
|
|
1576
|
+
"context",
|
|
773
1577
|
);
|
|
774
1578
|
|
|
775
1579
|
checkPlannerCompletion();
|
|
@@ -796,7 +1600,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
796
1600
|
orchestrator.abortAllSubagents();
|
|
797
1601
|
}
|
|
798
1602
|
|
|
799
|
-
|
|
1603
|
+
orchestrator.transitionController.sendCustom(
|
|
800
1604
|
{
|
|
801
1605
|
customType: "pp-subagent-error",
|
|
802
1606
|
content: isApiError
|
|
@@ -804,7 +1608,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
804
1608
|
: `**${desc}** failed: ${data.error || "unknown error"}. Do NOT retry — continue with available information.`,
|
|
805
1609
|
display: true,
|
|
806
1610
|
},
|
|
807
|
-
|
|
1611
|
+
"context",
|
|
808
1612
|
);
|
|
809
1613
|
|
|
810
1614
|
checkPlannerCompletion();
|
|
@@ -812,6 +1616,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
812
1616
|
});
|
|
813
1617
|
|
|
814
1618
|
pi.on("session_before_switch" as any, async () => {
|
|
1619
|
+
finalizeTracer();
|
|
815
1620
|
if (!orchestrator.active) return;
|
|
816
1621
|
cancelPendingPlannotatorWait(orchestrator);
|
|
817
1622
|
orchestrator.abortAllSubagents();
|
|
@@ -827,14 +1632,47 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
827
1632
|
try {
|
|
828
1633
|
dumpUsageSummary(tracker, sessionId);
|
|
829
1634
|
} catch (err: any) {
|
|
830
|
-
|
|
1635
|
+
getLogger().error({ s: "usage", err: err.message }, "failed to dump usage summary");
|
|
831
1636
|
}
|
|
1637
|
+
flushLogs();
|
|
1638
|
+
finalizeTracer();
|
|
832
1639
|
delete (globalThis as any)[USAGE_TRACKER_KEY];
|
|
833
1640
|
});
|
|
834
1641
|
|
|
835
1642
|
pi.on("session_start", async (_event, ctx) => {
|
|
836
1643
|
orchestrator.lastCtx = ctx;
|
|
837
1644
|
orchestrator.cwd = ctx.cwd;
|
|
1645
|
+
(globalThis as any)[Symbol.for("pi-pi:orchestrator-cwd")] = ctx.cwd;
|
|
1646
|
+
|
|
1647
|
+
const ppDir = join(ctx.cwd, ".pp");
|
|
1648
|
+
const { ensureGitignore } = await import("./orchestrator.js");
|
|
1649
|
+
ensureGitignore(ctx.cwd);
|
|
1650
|
+
|
|
1651
|
+
let earlyLogLevel: "debug" | "info" | "warn" | "error" = "info";
|
|
1652
|
+
try {
|
|
1653
|
+
const { readRawConfig, GLOBAL_CONFIG_PATH } = await import("./config.js");
|
|
1654
|
+
const { isValidLogLevel: checkLevel } = await import("./log.js");
|
|
1655
|
+
const globalRaw = readRawConfig(GLOBAL_CONFIG_PATH);
|
|
1656
|
+
const projectRaw = readRawConfig(join(ppDir, "config.json"));
|
|
1657
|
+
const rawLevel = projectRaw?.general?.logLevel ?? globalRaw?.general?.logLevel;
|
|
1658
|
+
if (checkLevel(rawLevel)) earlyLogLevel = rawLevel;
|
|
1659
|
+
} catch {}
|
|
1660
|
+
|
|
1661
|
+
initSessionLogger(ppDir, earlyLogLevel);
|
|
1662
|
+
const log = getLogger();
|
|
1663
|
+
log.info({ s: "session", cwd: ctx.cwd, logLevel: earlyLogLevel }, "session started");
|
|
1664
|
+
|
|
1665
|
+
const available = (ctx as any).modelRegistry?.getAvailable?.();
|
|
1666
|
+
if (Array.isArray(available)) {
|
|
1667
|
+
const modelIds = available
|
|
1668
|
+
.map((m: any) => {
|
|
1669
|
+
const provider = typeof m?.provider === "string" ? m.provider.trim() : "";
|
|
1670
|
+
const id = typeof m?.id === "string" ? m.id.trim() : "";
|
|
1671
|
+
return provider && id ? `${provider}/${id}` : "";
|
|
1672
|
+
})
|
|
1673
|
+
.filter((id: string) => id.length > 0);
|
|
1674
|
+
updateRegistryFromAvailableModels(modelIds);
|
|
1675
|
+
}
|
|
838
1676
|
|
|
839
1677
|
if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
840
1678
|
const tracker = createUsageTracker();
|
|
@@ -864,7 +1702,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
864
1702
|
`Duplicate tools detected: ${duplicates.join(", ")}. ` +
|
|
865
1703
|
`Remove the conflicting packages: pi remove npm:@tintinweb/pi-subagents npm:@tintinweb/pi-tasks npm:pi-ask-user`;
|
|
866
1704
|
ctx.ui.notify(msg, "error");
|
|
867
|
-
|
|
1705
|
+
getLogger().error({ s: "init", duplicates }, "conflicting extensions detected");
|
|
868
1706
|
return;
|
|
869
1707
|
}
|
|
870
1708
|
|
|
@@ -873,16 +1711,28 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
873
1711
|
setPI(pi);
|
|
874
1712
|
await initFlantOnStartup(pi);
|
|
875
1713
|
} catch (err: any) {
|
|
876
|
-
|
|
1714
|
+
getLogger().error({ s: "flant", err: err.message }, "flant infra init failed");
|
|
877
1715
|
}
|
|
878
1716
|
|
|
879
1717
|
try {
|
|
880
1718
|
orchestrator.config = loadConfig(orchestrator.cwd);
|
|
881
1719
|
} catch (err: any) {
|
|
882
|
-
|
|
1720
|
+
getLogger().error({ s: "config", err: err.message }, "failed to load config on session start");
|
|
883
1721
|
return;
|
|
884
1722
|
}
|
|
885
1723
|
|
|
1724
|
+
setLogLevel(orchestrator.config.general.logLevel);
|
|
1725
|
+
log.info({ s: "config", logLevel: orchestrator.config.general.logLevel }, "config loaded");
|
|
1726
|
+
|
|
1727
|
+
if (orchestrator.config.general.tracing) {
|
|
1728
|
+
const sessionId = ctx.sessionManager?.getSessionId?.() || `session-${Date.now()}`;
|
|
1729
|
+
initTracer(ppDir, sessionId);
|
|
1730
|
+
log.info({ s: "tracing", sessionId }, "session tracing enabled");
|
|
1731
|
+
} else {
|
|
1732
|
+
finalizeTracer();
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
registerCommandHandlers(orchestrator);
|
|
886
1736
|
registerCbmTools(pi, orchestrator.cwd);
|
|
887
1737
|
registerExaTools(pi);
|
|
888
1738
|
registerAstSearchTool(pi, orchestrator.cwd);
|
|
@@ -890,7 +1740,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
890
1740
|
setExtensionOnlyMode(pi);
|
|
891
1741
|
orchestrator.registerAgents();
|
|
892
1742
|
|
|
893
|
-
const found = getActiveTask(orchestrator.cwd, orchestrator.config.
|
|
1743
|
+
const found = getActiveTask(orchestrator.cwd, orchestrator.config.performance.internals.taskLockStale);
|
|
894
1744
|
if (found) {
|
|
895
1745
|
ctx.ui.notify(
|
|
896
1746
|
`Paused task: "${taskName(found.dir)}" (${found.type}, phase: ${found.state.phase}). Run /pp and choose Resume to continue.`,
|
|
@@ -912,34 +1762,77 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
912
1762
|
|
|
913
1763
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
914
1764
|
orchestrator.lastCtx = ctx;
|
|
915
|
-
|
|
916
|
-
|
|
1765
|
+
// The controller owns the transition/await abort gate: it decides whether the
|
|
1766
|
+
// agent loop may start (not running during a pending/compacting/resuming
|
|
1767
|
+
// transition, subsuming the old compaction-pending checks, or while awaiting
|
|
1768
|
+
// subagents) AND issues the abort itself.
|
|
1769
|
+
if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
|
|
917
1770
|
return;
|
|
918
1771
|
}
|
|
919
1772
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
920
1773
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1774
|
+
// Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
|
|
1775
|
+
// injected prompts (nudges, "Begin working" handoffs, planner-error prompts)
|
|
1776
|
+
// are all "[PI-PI]"-prefixed and themselves restart the loop via followUp;
|
|
1777
|
+
// resetting on those would make consecutiveNudges oscillate 0->1->0 and the
|
|
1778
|
+
// halt could never fire. A nudge-induced restart must NOT clear the guard.
|
|
1779
|
+
const isControllerInjected = (event.prompt ?? "").startsWith("[PI-PI]");
|
|
1780
|
+
if (!isControllerInjected) {
|
|
1781
|
+
orchestrator.nudgeHalted = false;
|
|
1782
|
+
orchestrator.consecutiveNudges = 0;
|
|
925
1783
|
}
|
|
926
|
-
|
|
927
|
-
orchestrator.nudgeHalted = false;
|
|
928
1784
|
orchestrator.updateStatus(ctx);
|
|
929
1785
|
|
|
930
1786
|
const phasePrompt = orchestrator.getPhasePrompt(ctx);
|
|
931
|
-
const
|
|
1787
|
+
const phase = orchestrator.active?.state.phase;
|
|
1788
|
+
const modelSpec = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
1789
|
+
const modelInfo = getModelInfo(modelSpec);
|
|
1790
|
+
const repos = orchestrator.active?.state.repos ?? [];
|
|
1791
|
+
const contextDirs = getContextDirs(orchestrator.cwd, repos, orchestrator.config.general.loadExtraRepoConfigs);
|
|
1792
|
+
const systemContextFiles = loadAllContextFiles(contextDirs, "main", "system", phase, modelInfo);
|
|
932
1793
|
const systemSnippets = systemContextFiles.map((f) => f.content).join("\n\n");
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
1794
|
+
const effectiveMode: TaskMode = getEffectivePhaseMode(orchestrator.active.state);
|
|
1795
|
+
const now = new Date();
|
|
1796
|
+
const monthYear = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
1797
|
+
|
|
1798
|
+
const projectContext = systemSnippets
|
|
1799
|
+
? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
|
|
1800
|
+
: "";
|
|
1801
|
+
const checklistLine =
|
|
1802
|
+
phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
|
|
1803
|
+
const taskBlock = [
|
|
1804
|
+
"<task>",
|
|
1805
|
+
phasePrompt,
|
|
1806
|
+
checklistLine,
|
|
1807
|
+
`Current month: ${monthYear}. Working directory: ${orchestrator.cwd}.`,
|
|
1808
|
+
"</task>",
|
|
1809
|
+
]
|
|
1810
|
+
.filter(Boolean)
|
|
1811
|
+
.join("\n");
|
|
1812
|
+
|
|
1813
|
+
const fullPrompt = [
|
|
1814
|
+
constraintsBlock(phase as Phase, effectiveMode),
|
|
1815
|
+
PRINCIPLES_BLOCK,
|
|
1816
|
+
TOOLS_BLOCK,
|
|
1817
|
+
projectContext,
|
|
1818
|
+
taskBlock,
|
|
1819
|
+
]
|
|
1820
|
+
.filter(Boolean)
|
|
1821
|
+
.join("\n\n");
|
|
936
1822
|
|
|
937
1823
|
return {
|
|
938
|
-
systemPrompt:
|
|
1824
|
+
systemPrompt: fullPrompt,
|
|
939
1825
|
};
|
|
940
1826
|
});
|
|
941
1827
|
|
|
942
1828
|
pi.on("tool_call", async (event, _ctx) => {
|
|
1829
|
+
getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
|
|
1830
|
+
if (event.toolName === "ask_user" && orchestrator.active) {
|
|
1831
|
+
if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
|
|
1832
|
+
return { block: true, reason: "Autonomous mode — make your best judgment based on available context." };
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
943
1836
|
if (event.toolName === "Agent" && orchestrator.active) {
|
|
944
1837
|
const input = event.input as Record<string, unknown>;
|
|
945
1838
|
const requestedType = ((input.subagent_type as string) || "").toLowerCase();
|
|
@@ -951,16 +1844,16 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
951
1844
|
|
|
952
1845
|
if (isExplore) {
|
|
953
1846
|
input.subagent_type = "explore";
|
|
954
|
-
input.model = orchestrator.config.agents.explore.model;
|
|
955
|
-
input.thinking = orchestrator.config.agents.explore.thinking;
|
|
1847
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
|
|
1848
|
+
input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
|
|
956
1849
|
} else if (isLibrarian) {
|
|
957
1850
|
input.subagent_type = "librarian";
|
|
958
|
-
input.model = orchestrator.config.agents.librarian.model;
|
|
959
|
-
input.thinking = orchestrator.config.agents.librarian.thinking;
|
|
1851
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
|
|
1852
|
+
input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
|
|
960
1853
|
} else {
|
|
961
1854
|
input.subagent_type = "task";
|
|
962
|
-
input.model = orchestrator.config.agents.task.model;
|
|
963
|
-
input.thinking = orchestrator.config.agents.task.thinking;
|
|
1855
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
|
|
1856
|
+
input.thinking = orchestrator.config.agents.subagents.simple.task.thinking;
|
|
964
1857
|
}
|
|
965
1858
|
}
|
|
966
1859
|
|
|
@@ -971,25 +1864,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
971
1864
|
const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
|
|
972
1865
|
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
973
1866
|
|
|
974
|
-
if (
|
|
1867
|
+
if (isPathInside(ppStateDir, resolvedPath)) {
|
|
975
1868
|
if (!resolvedPath.endsWith(".md")) {
|
|
976
1869
|
return { block: true, reason: "Cannot write non-.md files in .pp/state/" };
|
|
977
1870
|
}
|
|
978
1871
|
}
|
|
979
1872
|
|
|
980
1873
|
const fileName = basename(resolvedPath);
|
|
981
|
-
if (fileName === "state.json" && (
|
|
1874
|
+
if (fileName === "state.json" && isPathInside(ppDir, resolvedPath)) {
|
|
982
1875
|
return { block: true, reason: "state.json is managed by the extension" };
|
|
983
1876
|
}
|
|
984
1877
|
|
|
985
|
-
if (fileName === "config.json" && (
|
|
1878
|
+
if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
|
|
986
1879
|
return { block: true, reason: "config.json is managed by the user, not the LLM" };
|
|
987
1880
|
}
|
|
988
1881
|
}
|
|
989
1882
|
return;
|
|
990
1883
|
});
|
|
991
1884
|
|
|
992
|
-
pi.on("tool_result", async (event,
|
|
1885
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1886
|
+
// ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
|
|
1887
|
+
// "stop, I want to type"). Only a deliberate user cancel aborts — timeout
|
|
1888
|
+
// and programmatic signal-aborts carry a non-"user" reason and must not.
|
|
1889
|
+
// The benign tool result has already been produced, so abort() yields a
|
|
1890
|
+
// clean stop rather than an error in the agent loop.
|
|
1891
|
+
if (event.toolName === "ask_user") {
|
|
1892
|
+
const cancelReason = (event.details as { cancelReason?: string } | undefined)?.cancelReason;
|
|
1893
|
+
if (cancelReason === "user") {
|
|
1894
|
+
getLogger().debug({ s: "hook", hook: "tool_result", tool: "ask_user" }, "user cancelled ask_user — aborting turn");
|
|
1895
|
+
ctx.abort?.();
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
993
1899
|
if (!orchestrator.active) return;
|
|
994
1900
|
|
|
995
1901
|
if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
|
|
@@ -1024,7 +1930,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1024
1930
|
}
|
|
1025
1931
|
}
|
|
1026
1932
|
const artifactsDir = join(taskDir, "artifacts");
|
|
1027
|
-
if (
|
|
1933
|
+
if (isPathInside(artifactsDir, resolvedWrite) && resolvedWrite.endsWith(".md")) {
|
|
1028
1934
|
const content = readFileSync(resolvedWrite, "utf-8");
|
|
1029
1935
|
const result = validateArtifact(content);
|
|
1030
1936
|
if (!result.ok) {
|
|
@@ -1037,24 +1943,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1037
1943
|
}
|
|
1038
1944
|
}
|
|
1039
1945
|
|
|
1040
|
-
|
|
1946
|
+
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
1947
|
+
if (isPathInside(ppDir, resolvedWrite)) return;
|
|
1041
1948
|
|
|
1042
1949
|
if (orchestrator.active.state.phase !== "implement") return;
|
|
1043
1950
|
|
|
1044
|
-
orchestrator.active.modifiedFiles.add(
|
|
1951
|
+
orchestrator.active.modifiedFiles.add(resolvedWrite);
|
|
1045
1952
|
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
1046
|
-
|
|
1047
|
-
const dir = resolve(filePath, "..");
|
|
1048
|
-
try {
|
|
1049
|
-
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
|
|
1050
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
1051
|
-
orchestrator.active.state.repoCwd = result.stdout.trim();
|
|
1052
|
-
}
|
|
1053
|
-
} catch {}
|
|
1054
|
-
}
|
|
1953
|
+
orchestrator.active.state.reviewApprovedClean = false;
|
|
1055
1954
|
try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
|
|
1056
1955
|
|
|
1057
|
-
const
|
|
1956
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
1957
|
+
const repo = resolveRepoForFile(repos, resolvedWrite);
|
|
1958
|
+
getLogger().debug({ s: "afterEdit", file: resolvedWrite, repo: repo?.path ?? null, isRoot: repo?.isRoot ?? null }, "resolving afterEdit commands");
|
|
1959
|
+
const afterEditResults: Array<{ ok: boolean; command: string; output: string }> = [];
|
|
1960
|
+
if (repo) {
|
|
1961
|
+
if (repo.isRoot) {
|
|
1962
|
+
const fileInRepo = relative(orchestrator.cwd, resolvedWrite);
|
|
1963
|
+
afterEditResults.push(
|
|
1964
|
+
...runAfterEdit(
|
|
1965
|
+
fileInRepo,
|
|
1966
|
+
orchestrator.config.commands.afterEdit,
|
|
1967
|
+
orchestrator.config.performance.commands.afterEdit,
|
|
1968
|
+
orchestrator.cwd,
|
|
1969
|
+
),
|
|
1970
|
+
);
|
|
1971
|
+
} else if (orchestrator.config.general.loadExtraRepoConfigs) {
|
|
1972
|
+
const repoCommands = loadRepoAfterEditCommands(repo.path);
|
|
1973
|
+
if (repoCommands && Object.keys(repoCommands).length > 0) {
|
|
1974
|
+
const fileInRepo = relative(repo.path, resolvedWrite);
|
|
1975
|
+
afterEditResults.push(...runAfterEdit(fileInRepo, repoCommands, orchestrator.config.performance.commands.afterEdit, repo.path));
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1058
1979
|
const failures = afterEditResults.filter((r) => !r.ok);
|
|
1059
1980
|
|
|
1060
1981
|
if (failures.length > 0) {
|
|
@@ -1083,10 +2004,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1083
2004
|
});
|
|
1084
2005
|
|
|
1085
2006
|
pi.on("session_before_compact", async (event, _ctx) => {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
2007
|
+
const transitioning = orchestrator.transitionController.isTransitioning();
|
|
2008
|
+
getLogger().debug({ s: "hook", hook: "session_before_compact", transitioning, state: orchestrator.transitionController.getState() }, "before compact");
|
|
2009
|
+
// Controller-initiated compaction (phase transition or task done/stop/new-task):
|
|
2010
|
+
// supply the transition summary. The controller is the single source of truth,
|
|
2011
|
+
// so this works whether or not `active` is still set (done cleanup nulls it).
|
|
2012
|
+
if (transitioning) {
|
|
2013
|
+
const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
|
|
1090
2014
|
return {
|
|
1091
2015
|
compaction: {
|
|
1092
2016
|
summary,
|
|
@@ -1096,18 +2020,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1096
2020
|
};
|
|
1097
2021
|
}
|
|
1098
2022
|
|
|
2023
|
+
// Natural (user-triggered) compaction: re-inject phase artifacts so context
|
|
2024
|
+
// survives.
|
|
1099
2025
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1100
2026
|
|
|
1101
|
-
if (orchestrator.phaseCompactionPending) {
|
|
1102
|
-
return {
|
|
1103
|
-
compaction: {
|
|
1104
|
-
summary: `Previous phase (${orchestrator.active.state.phase}) completed. Transitioning to next phase.`,
|
|
1105
|
-
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
1106
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
1107
|
-
},
|
|
1108
|
-
};
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
2027
|
const artifacts = getPhaseArtifacts(orchestrator.active.dir, orchestrator.active.state.phase);
|
|
1112
2028
|
if (artifacts.length === 0) return;
|
|
1113
2029
|
|
|
@@ -1115,13 +2031,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1115
2031
|
.map((a) => `=== ${a.name} ===\n${a.content}`)
|
|
1116
2032
|
.join("\n\n");
|
|
1117
2033
|
|
|
1118
|
-
|
|
2034
|
+
orchestrator.transitionController.sendCustom(
|
|
1119
2035
|
{
|
|
1120
2036
|
customType: "pp-artifact-reinject",
|
|
1121
2037
|
content: `[PI-PI ARTIFACTS — re-injected after compaction]\n\n${artifactText}`,
|
|
1122
2038
|
display: false,
|
|
1123
2039
|
},
|
|
1124
|
-
|
|
2040
|
+
"context",
|
|
1125
2041
|
);
|
|
1126
2042
|
|
|
1127
2043
|
return;
|
|
@@ -1133,32 +2049,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1133
2049
|
if (usageTracker && msg?.usage) {
|
|
1134
2050
|
const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
|
|
1135
2051
|
const output = typeof msg.usage.output === "number" ? msg.usage.output : 0;
|
|
2052
|
+
const cacheSupported = typeof msg.usage.cacheRead === "number" || typeof msg.usage.cacheWrite === "number";
|
|
1136
2053
|
const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
|
|
1137
2054
|
const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
|
|
1138
2055
|
const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
|
|
1139
2056
|
const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
|
|
1140
2057
|
const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
|
|
1141
|
-
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
|
|
2058
|
+
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost, cacheSupported);
|
|
1142
2059
|
(ctx.ui as any)?.requestRender?.();
|
|
1143
2060
|
}
|
|
1144
2061
|
|
|
1145
2062
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
2063
|
+
// A transition is in flight (controller owns the loop) — skip orthogonal
|
|
2064
|
+
// turn_end work (commit reminder / nudges) until it resumes.
|
|
2065
|
+
if (orchestrator.transitionController.isTransitioning()) return;
|
|
1146
2066
|
orchestrator.updateStatus(ctx);
|
|
1147
2067
|
|
|
1148
2068
|
if (
|
|
1149
2069
|
orchestrator.active.state.phase === "implement" &&
|
|
1150
|
-
orchestrator.config.autoCommit &&
|
|
2070
|
+
orchestrator.config.general.autoCommit &&
|
|
1151
2071
|
orchestrator.active.modifiedFiles.size > 0 &&
|
|
1152
2072
|
!orchestrator.commitReminderSent
|
|
1153
2073
|
) {
|
|
1154
2074
|
orchestrator.commitReminderSent = true;
|
|
1155
|
-
|
|
2075
|
+
orchestrator.transitionController.sendCustom(
|
|
1156
2076
|
{
|
|
1157
2077
|
customType: "pp-commit-reminder",
|
|
1158
|
-
content: `You have ${orchestrator.active.modifiedFiles.size} uncommitted file(s). If you've completed a logical unit of work, call pp_commit with a descriptive message.`,
|
|
2078
|
+
content: `You have ${orchestrator.active.modifiedFiles.size} uncommitted file(s). If you've completed a logical unit of work, call pp_commit with a descriptive message. Prefix it with a conventional-commit type (fix:, feat:, or chore:) unless the user asked for a different commit style.`,
|
|
1159
2079
|
display: false,
|
|
1160
2080
|
},
|
|
1161
|
-
|
|
2081
|
+
"context",
|
|
1162
2082
|
);
|
|
1163
2083
|
}
|
|
1164
2084
|
|
|
@@ -1167,29 +2087,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1167
2087
|
if (msg?.stopReason === "aborted") return;
|
|
1168
2088
|
if (msg?.stopReason === "error") {
|
|
1169
2089
|
const errorMsg = msg.errorMessage || "unknown error";
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
2090
|
+
const contentSummary = (msg.content || []).map((c: any) => {
|
|
2091
|
+
if (c.type === "toolCall") return `toolCall:${c.name}`;
|
|
2092
|
+
if (c.type === "text" && c.text) return `text:${c.text.slice(0, 100)}`;
|
|
2093
|
+
if (c.type === "thinking") return `thinking:${c.thinking?.slice(0, 100) || "(redacted)"}`;
|
|
2094
|
+
return c.type;
|
|
2095
|
+
});
|
|
2096
|
+
getLogger().error({
|
|
2097
|
+
s: "turn",
|
|
2098
|
+
err: errorMsg,
|
|
2099
|
+
model: msg.model,
|
|
2100
|
+
provider: msg.provider,
|
|
2101
|
+
api: msg.api,
|
|
2102
|
+
input: msg.usage?.input,
|
|
2103
|
+
output: msg.usage?.output,
|
|
2104
|
+
cacheRead: msg.usage?.cacheRead,
|
|
2105
|
+
contentBlocks: msg.content?.length ?? 0,
|
|
2106
|
+
contentSummary,
|
|
2107
|
+
}, "turn ended with error");
|
|
1179
2108
|
orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
|
|
1180
|
-
const
|
|
1181
|
-
if (orchestrator.errorRetryCount <=
|
|
1182
|
-
const delay =
|
|
1183
|
-
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${
|
|
2109
|
+
const maxRetries = 5;
|
|
2110
|
+
if (orchestrator.errorRetryCount <= maxRetries) {
|
|
2111
|
+
const delay = 2000 * Math.pow(2, orchestrator.errorRetryCount - 1);
|
|
2112
|
+
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
|
|
1184
2113
|
const taskToken = orchestrator.activeTaskToken;
|
|
1185
2114
|
if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
|
|
1186
2115
|
orchestrator.pendingRetryTimer = setTimeout(() => {
|
|
1187
2116
|
orchestrator.pendingRetryTimer = null;
|
|
1188
2117
|
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
|
|
1189
|
-
|
|
2118
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
|
|
1190
2119
|
}, delay);
|
|
1191
2120
|
} else {
|
|
1192
|
-
ctx.ui.notify(`API error persisted after
|
|
2121
|
+
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
|
|
1193
2122
|
orchestrator.errorRetryCount = 0;
|
|
1194
2123
|
}
|
|
1195
2124
|
return;
|
|
@@ -1200,75 +2129,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1200
2129
|
return;
|
|
1201
2130
|
}
|
|
1202
2131
|
|
|
2132
|
+
// While awaiting subagents, completion is driven entirely by the event-driven
|
|
2133
|
+
// owners (checkPlannerCompletion / checkReviewCycleCompletion / tryCompleteReviewCycle,
|
|
2134
|
+
// wired to subagents:completed/failed and the blocking spawn promise's onSettled).
|
|
2135
|
+
// The old 5s setInterval poller was a redundant fallback and has been removed;
|
|
2136
|
+
// turn_end simply returns here (no nudge, no poll) while in an await_* step.
|
|
1203
2137
|
if (orchestrator.active.state.step === "await_planners" || orchestrator.active.state.step === "await_reviewers") {
|
|
1204
|
-
if (!orchestrator.awaitPollTimer) {
|
|
1205
|
-
orchestrator.awaitPollTimer = setInterval(() => {
|
|
1206
|
-
if (!orchestrator.active) {
|
|
1207
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1208
|
-
orchestrator.awaitPollTimer = null;
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
const taskDir = orchestrator.active.dir;
|
|
1212
|
-
if (orchestrator.active.state.step === "await_planners") {
|
|
1213
|
-
const plansDir = join(taskDir, "plans");
|
|
1214
|
-
const plannerCount = Object.values(orchestrator.config.planners).filter((v) => v.enabled).length;
|
|
1215
|
-
if (existsSync(plansDir)) {
|
|
1216
|
-
const planFiles = readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
|
|
1217
|
-
if (planFiles.length >= plannerCount) {
|
|
1218
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1219
|
-
orchestrator.awaitPollTimer = null;
|
|
1220
|
-
orchestrator.spawnedAgentIds.clear();
|
|
1221
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
1222
|
-
orchestrator.active.state.step = "synthesize";
|
|
1223
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1224
|
-
pi.sendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.", { deliverAs: "followUp" });
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
} else if (orchestrator.active.state.step === "await_reviewers" && orchestrator.active.state.reviewCycle) {
|
|
1228
|
-
const cycle = orchestrator.active.state.reviewCycle;
|
|
1229
|
-
const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
|
|
1230
|
-
const phase = orchestrator.active.state.phase;
|
|
1231
|
-
const reviewers = phase === "brainstorm"
|
|
1232
|
-
? reviewConfig.brainstormReviewers
|
|
1233
|
-
: phase === "plan"
|
|
1234
|
-
? reviewConfig.planReviewers
|
|
1235
|
-
: reviewConfig.codeReviewers;
|
|
1236
|
-
const reviewerCount = Object.values(reviewers).filter((v) => v.enabled).length;
|
|
1237
|
-
const outputs = loadPhaseReviewOutputs(taskDir, phase, cycle.pass);
|
|
1238
|
-
if (outputs.length >= reviewerCount) {
|
|
1239
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1240
|
-
orchestrator.awaitPollTimer = null;
|
|
1241
|
-
orchestrator.spawnedAgentIds.clear();
|
|
1242
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
1243
|
-
|
|
1244
|
-
if (orchestrator.reviewTransitionToken === orchestrator.activeTaskToken) return;
|
|
1245
|
-
orchestrator.reviewTransitionToken = orchestrator.activeTaskToken;
|
|
1246
|
-
|
|
1247
|
-
cycle.step = "apply_feedback";
|
|
1248
|
-
orchestrator.active.state.step = "apply_feedback";
|
|
1249
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1250
|
-
const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
|
|
1251
|
-
pi.sendMessage(
|
|
1252
|
-
{ customType: "pp-review-ready", content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`, display: false },
|
|
1253
|
-
{ deliverAs: "followUp" },
|
|
1254
|
-
);
|
|
1255
|
-
pi.sendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.", { deliverAs: "followUp" });
|
|
1256
|
-
}
|
|
1257
|
-
} else {
|
|
1258
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1259
|
-
orchestrator.awaitPollTimer = null;
|
|
1260
|
-
}
|
|
1261
|
-
}, 5000);
|
|
1262
|
-
}
|
|
1263
2138
|
return;
|
|
1264
2139
|
}
|
|
1265
|
-
if (orchestrator.awaitPollTimer) {
|
|
1266
|
-
clearInterval(orchestrator.awaitPollTimer);
|
|
1267
|
-
orchestrator.awaitPollTimer = null;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
|
|
1271
|
-
if (orchestrator.active.type === "review" && phase === "review") return;
|
|
1272
2140
|
|
|
1273
2141
|
const contentParts = Array.isArray(msg?.content) ? msg.content : [];
|
|
1274
2142
|
const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
|
|
@@ -1276,88 +2144,51 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1276
2144
|
const hasToolResults = event.toolResults && event.toolResults.length > 0;
|
|
1277
2145
|
const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
|
|
1278
2146
|
|
|
1279
|
-
|
|
1280
|
-
const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
|
|
1281
|
-
const endsWithText = lastPart?.type === "text" && lastPart?.text?.trim();
|
|
1282
|
-
if (hasText && (!hasToolCalls || endsWithText)) {
|
|
1283
|
-
const step = orchestrator.active.state.step;
|
|
1284
|
-
if (step !== "await_planners" && step !== "await_reviewers") {
|
|
1285
|
-
pi.sendMessage(
|
|
1286
|
-
{
|
|
1287
|
-
customType: "pp-phase-complete-reminder",
|
|
1288
|
-
content: `[PI-PI] You stopped without calling pp_phase_complete. If you are done with the ${phase} phase, call pp_phase_complete now. If not, continue working.`,
|
|
1289
|
-
display: false,
|
|
1290
|
-
},
|
|
1291
|
-
{ deliverAs: "followUp" },
|
|
1292
|
-
);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
return;
|
|
1296
|
-
}
|
|
1297
|
-
if (orchestrator.nudgeHalted) return;
|
|
1298
|
-
if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) return;
|
|
2147
|
+
const isAutonomous = getEffectivePhaseMode(orchestrator.active.state) === "autonomous";
|
|
1299
2148
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
2149
|
+
// Nudges fire ONLY for plan/implement. brainstorm/debug/review (and quick) are
|
|
2150
|
+
// interactive by nature — stopping there is normal, not a stall. They are also
|
|
2151
|
+
// suppressed whenever the controller is not running (a transition handoff or
|
|
2152
|
+
// await is in progress — that pause is plumbing, not a genuine model stop).
|
|
2153
|
+
const nudgesEnabled = (phase === "plan" || phase === "implement") && orchestrator.transitionController.isRunning();
|
|
1304
2154
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
customType: "pp-continuation",
|
|
1312
|
-
content: `[PI-PI] Your previous response was interrupted. Continue working on the current phase (${phase}). Pick up where you left off.`,
|
|
1313
|
-
display: false,
|
|
1314
|
-
},
|
|
1315
|
-
{ deliverAs: "followUp" },
|
|
1316
|
-
);
|
|
1317
|
-
};
|
|
2155
|
+
// A genuine model stop is a turn that produced no forward progress: either a
|
|
2156
|
+
// text-only reply (no tool call, or one that trails into text) or a fully
|
|
2157
|
+
// empty turn. A turn that ended on a tool call IS progress and resets the guard.
|
|
2158
|
+
const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
|
|
2159
|
+
const endsWithText = lastPart?.type === "text" && !!lastPart?.text?.trim();
|
|
2160
|
+
const isGenuineStop = turnWasEmpty || (hasText && (!hasToolCalls || endsWithText));
|
|
1318
2161
|
|
|
1319
|
-
if (
|
|
1320
|
-
|
|
2162
|
+
if (!isGenuineStop) {
|
|
2163
|
+
// Forward progress — clear the consecutive-nudge guard.
|
|
2164
|
+
orchestrator.consecutiveNudges = 0;
|
|
1321
2165
|
return;
|
|
1322
2166
|
}
|
|
1323
2167
|
|
|
1324
|
-
if (orchestrator.
|
|
1325
|
-
orchestrator.cooldownHits.push(now);
|
|
1326
|
-
orchestrator.cooldownHits = orchestrator.cooldownHits.filter((t) => now - t < 20 * 60 * 1000);
|
|
1327
|
-
|
|
1328
|
-
if (orchestrator.cooldownHits.length >= 5) {
|
|
1329
|
-
orchestrator.nudgeHalted = true;
|
|
1330
|
-
orchestrator.nudgeTimestamps = [];
|
|
1331
|
-
orchestrator.cooldownHits = [];
|
|
1332
|
-
pi.sendMessage(
|
|
1333
|
-
{
|
|
1334
|
-
customType: "pp-continuation-halted",
|
|
1335
|
-
content: "Agent has been repeatedly interrupted. Auto-continuation paused. Send any message to resume nudging.",
|
|
1336
|
-
display: true,
|
|
1337
|
-
},
|
|
1338
|
-
{ deliverAs: "steer" },
|
|
1339
|
-
);
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
2168
|
+
if (!nudgesEnabled || orchestrator.nudgeHalted) return;
|
|
1342
2169
|
|
|
1343
|
-
|
|
2170
|
+
// Single consecutive-nudge guard: nudge up to MAX_CONSECUTIVE_NUDGES times in
|
|
2171
|
+
// a row, then halt with one notification until the user re-engages (a fresh
|
|
2172
|
+
// before_agent_start resets the counter).
|
|
2173
|
+
const MAX_CONSECUTIVE_NUDGES = 6;
|
|
2174
|
+
if (orchestrator.consecutiveNudges >= MAX_CONSECUTIVE_NUDGES) {
|
|
2175
|
+
orchestrator.nudgeHalted = true;
|
|
2176
|
+
orchestrator.transitionController.sendCustom(
|
|
1344
2177
|
{
|
|
1345
|
-
customType: "pp-continuation-
|
|
1346
|
-
content: "Agent interrupted
|
|
2178
|
+
customType: "pp-continuation-halted",
|
|
2179
|
+
content: "Agent has been repeatedly interrupted without making progress. Auto-continuation paused. Send any message to resume.",
|
|
1347
2180
|
display: true,
|
|
1348
2181
|
},
|
|
1349
|
-
|
|
2182
|
+
"context",
|
|
1350
2183
|
);
|
|
1351
|
-
orchestrator.nudgeTimestamps = [];
|
|
1352
|
-
const cooldownToken = orchestrator.activeTaskToken;
|
|
1353
|
-
setTimeout(() => {
|
|
1354
|
-
if (orchestrator.activeTaskToken !== cooldownToken || !orchestrator.active) return;
|
|
1355
|
-
sendNudge();
|
|
1356
|
-
}, 60000);
|
|
1357
2184
|
return;
|
|
1358
2185
|
}
|
|
1359
2186
|
|
|
1360
|
-
|
|
2187
|
+
orchestrator.consecutiveNudges++;
|
|
2188
|
+
const nudge = isAutonomous
|
|
2189
|
+
? `[PI-PI] Continue the ${phase} phase. ${phaseConstraint(phase as Phase)} If the phase's objectives are met, call pp_phase_complete now; otherwise call the next tool. Do NOT apologize or reply with text only — respond with a tool call.`
|
|
2190
|
+
: `[PI-PI] Continue the ${phase} phase where you left off.`;
|
|
2191
|
+
orchestrator.safeSendUserMessage(nudge);
|
|
1361
2192
|
});
|
|
1362
2193
|
|
|
1363
2194
|
|