@ilya-lesikov/pi-pi 0.5.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 +68 -28
- 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 +1177 -341
- 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 +287 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- 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 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- 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 +2557 -347
- 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
|
-
orchestrator.safeSendUserMessage(
|
|
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,41 +298,77 @@ 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();
|
|
355
|
+
orchestrator.active.state.reviewCycle = null;
|
|
207
356
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
208
357
|
const desc = orchestrator.active.description;
|
|
358
|
+
const type = orchestrator.active.type;
|
|
209
359
|
await orchestrator.cleanupActive();
|
|
210
360
|
const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
|
|
211
361
|
taskStore?.clearAll?.();
|
|
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
|
+
|
|
212
372
|
return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
|
|
213
373
|
}
|
|
214
374
|
|
|
@@ -217,19 +377,184 @@ export function finalizeReviewCycle(task: ActiveTask): void {
|
|
|
217
377
|
const kind = task.state.reviewCycle.kind;
|
|
218
378
|
task.state.reviewPass = task.state.reviewCycle.pass;
|
|
219
379
|
task.reviewPass = task.state.reviewPass;
|
|
220
|
-
|
|
221
|
-
task.state.reviewPassByKind[kind] = (task.state.reviewPassByKind[kind] ?? 0) + 1;
|
|
380
|
+
incrementReviewPass(task, kind);
|
|
222
381
|
task.state.reviewCycle = null;
|
|
223
382
|
task.state.step = "user_gate";
|
|
224
383
|
saveTask(task.dir, task.state);
|
|
225
384
|
}
|
|
226
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
|
+
|
|
227
412
|
function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
413
|
+
registerRepoTool(orchestrator);
|
|
228
414
|
registerPhaseCompleteTool(orchestrator);
|
|
229
415
|
registerCommitTool(orchestrator);
|
|
230
416
|
registerSpecifyReviewsTool(orchestrator);
|
|
231
417
|
}
|
|
232
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
|
+
|
|
233
558
|
function openCodeReviewDirect(
|
|
234
559
|
pi: ExtensionAPI,
|
|
235
560
|
payload: Record<string, unknown>,
|
|
@@ -259,7 +584,8 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
259
584
|
description:
|
|
260
585
|
"Specify which repositories and commit ranges to open in Plannotator for code review. " +
|
|
261
586
|
"Called when the user requests a Plannotator code review. " +
|
|
262
|
-
"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).",
|
|
263
589
|
parameters: Type.Object({
|
|
264
590
|
reviews: Type.Array(Type.Object({
|
|
265
591
|
cwd: Type.String({ description: "Absolute path to the git repository" }),
|
|
@@ -267,6 +593,9 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
267
593
|
})),
|
|
268
594
|
}),
|
|
269
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
|
+
}
|
|
270
599
|
if (!params.reviews || params.reviews.length === 0) {
|
|
271
600
|
return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
|
|
272
601
|
}
|
|
@@ -275,12 +604,89 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
275
604
|
let hasNeedsChanges = false;
|
|
276
605
|
for (const review of params.reviews) {
|
|
277
606
|
ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
|
|
278
|
-
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
|
+
|
|
279
667
|
const result = await openCodeReviewDirect(pi, {
|
|
280
|
-
cwd:
|
|
668
|
+
cwd: reviewCwd,
|
|
281
669
|
diffType: "branch",
|
|
282
|
-
defaultBranch:
|
|
670
|
+
defaultBranch: compareBase,
|
|
283
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
|
+
|
|
284
690
|
if ("error" in result) {
|
|
285
691
|
results.push(`${review.cwd} (${review.range}): ${result.error}`);
|
|
286
692
|
} else {
|
|
@@ -309,7 +715,10 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
309
715
|
try {
|
|
310
716
|
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
311
717
|
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
312
|
-
|
|
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()) {
|
|
313
722
|
ctx.abort?.();
|
|
314
723
|
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
315
724
|
}
|
|
@@ -326,7 +735,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
326
735
|
|
|
327
736
|
function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
|
|
328
737
|
if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
|
|
329
|
-
if (phase === "plan") return loadPlanReviewOutputs(taskDir);
|
|
738
|
+
if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
|
|
330
739
|
return loadCodeReviewOutputs(taskDir, pass);
|
|
331
740
|
}
|
|
332
741
|
|
|
@@ -339,37 +748,65 @@ function registerCommitTool(orchestrator: Orchestrator): void {
|
|
|
339
748
|
description:
|
|
340
749
|
"Commit modified files with a descriptive message. Call after completing a logical " +
|
|
341
750
|
"unit of work (e.g. implementing one plan item, fixing a bug, adding a test). " +
|
|
342
|
-
"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.",
|
|
343
754
|
parameters: Type.Object({
|
|
344
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." })),
|
|
345
757
|
}),
|
|
346
758
|
async execute(_toolCallId, params: any) {
|
|
347
759
|
if (!orchestrator.active) {
|
|
348
760
|
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
349
761
|
}
|
|
350
|
-
if (!orchestrator.config.autoCommit) {
|
|
762
|
+
if (!orchestrator.config.general.autoCommit) {
|
|
351
763
|
return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
|
|
352
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
|
+
|
|
353
779
|
const files: string[] = [];
|
|
354
780
|
try {
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
}
|
|
363
792
|
}
|
|
364
793
|
}
|
|
365
794
|
} catch {}
|
|
366
795
|
if (files.length === 0) {
|
|
367
796
|
return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
|
|
368
797
|
}
|
|
369
|
-
const result = autoCommit(files, params.message,
|
|
798
|
+
const result = autoCommit(files, params.message, commitRepoPath);
|
|
370
799
|
if (result.ok) {
|
|
371
|
-
orchestrator.active.modifiedFiles.
|
|
372
|
-
|
|
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];
|
|
373
810
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
374
811
|
orchestrator.commitReminderSent = false;
|
|
375
812
|
return { content: [{ type: "text" as const, text: `Committed ${files.length} file(s): ${result.commitHash ?? "ok"}` }], details: {} };
|
|
@@ -403,11 +840,85 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
403
840
|
const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
|
|
404
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: {} };
|
|
405
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
|
+
}
|
|
406
909
|
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
407
910
|
try {
|
|
408
911
|
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
409
912
|
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
|
|
410
|
-
|
|
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()) {
|
|
411
922
|
ctx.abort?.();
|
|
412
923
|
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
413
924
|
}
|
|
@@ -422,9 +933,89 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
422
933
|
});
|
|
423
934
|
}
|
|
424
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
|
+
|
|
425
991
|
export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
426
992
|
const pi = orchestrator.pi;
|
|
427
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
|
+
|
|
428
1019
|
function getUsageTracker(): UsageTracker | undefined {
|
|
429
1020
|
return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
|
|
430
1021
|
}
|
|
@@ -444,6 +1035,25 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
444
1035
|
if (event === "first_turn" && lifecycle.firstTurnAt == null) lifecycle.firstTurnAt = now;
|
|
445
1036
|
orchestrator.agentLifecycle.set(data.id, lifecycle);
|
|
446
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
|
+
|
|
447
1057
|
const ageMs = lifecycle.createdAt == null ? 0 : now - lifecycle.createdAt;
|
|
448
1058
|
const startedDeltaMs = lifecycle.startedAt == null || lifecycle.createdAt == null ? undefined : lifecycle.startedAt - lifecycle.createdAt;
|
|
449
1059
|
const firstToolDeltaMs = lifecycle.firstToolAt == null || lifecycle.createdAt == null ? undefined : lifecycle.firstToolAt - lifecycle.createdAt;
|
|
@@ -451,8 +1061,8 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
451
1061
|
const pending = orchestrator.pendingSubagentSpawns;
|
|
452
1062
|
const running = orchestrator.spawnedAgentIds.size;
|
|
453
1063
|
const desc = data.description || lifecycle.description || data.type || lifecycle.type || data.id;
|
|
454
|
-
|
|
455
|
-
|
|
1064
|
+
getLogger().debug({
|
|
1065
|
+
s: "subagent",
|
|
456
1066
|
id: data.id,
|
|
457
1067
|
event,
|
|
458
1068
|
description: desc,
|
|
@@ -467,7 +1077,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
467
1077
|
createdToFirstTurnMs: firstTurnDeltaMs ?? null,
|
|
468
1078
|
toolName: data.toolName ?? null,
|
|
469
1079
|
turnCount: data.turnCount ?? null,
|
|
470
|
-
});
|
|
1080
|
+
}, "subagent lifecycle event");
|
|
471
1081
|
}
|
|
472
1082
|
|
|
473
1083
|
function startStaleAgentWatchdog(): void {
|
|
@@ -480,7 +1090,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
480
1090
|
}
|
|
481
1091
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
482
1092
|
const now = Date.now();
|
|
483
|
-
const staleMs = orchestrator.config.
|
|
1093
|
+
const staleMs = orchestrator.config.performance.internals.subagentStale;
|
|
484
1094
|
for (const [id, spawnTime] of orchestrator.agentSpawnTimes) {
|
|
485
1095
|
const record = mgr?.getRecord?.(id);
|
|
486
1096
|
if (record?.status === "running" || record?.status === "queued") continue;
|
|
@@ -492,13 +1102,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
492
1102
|
orchestrator.agentSpawnTimes.delete(id);
|
|
493
1103
|
orchestrator.agentDescriptions.delete(id);
|
|
494
1104
|
orchestrator.agentLifecycle.delete(id);
|
|
495
|
-
|
|
1105
|
+
orchestrator.transitionController.sendCustom(
|
|
496
1106
|
{
|
|
497
1107
|
customType: "pp-agent-stale",
|
|
498
1108
|
content: `Aborted stale agent "${desc}" — no completion after ${Math.round(staleMs / 1000)}s.`,
|
|
499
1109
|
display: true,
|
|
500
1110
|
},
|
|
501
|
-
|
|
1111
|
+
"context",
|
|
502
1112
|
);
|
|
503
1113
|
}
|
|
504
1114
|
}
|
|
@@ -538,6 +1148,15 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
538
1148
|
trackSubagentEvent(data, "first_turn");
|
|
539
1149
|
});
|
|
540
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
|
+
|
|
541
1160
|
function checkPlannerCompletion(): void {
|
|
542
1161
|
if (
|
|
543
1162
|
!orchestrator.active ||
|
|
@@ -545,7 +1164,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
545
1164
|
orchestrator.active.state.step !== "await_planners" ||
|
|
546
1165
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
547
1166
|
orchestrator.pendingSubagentSpawns > 0 ||
|
|
548
|
-
orchestrator.
|
|
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,28 +1258,35 @@ 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
|
-
|
|
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
|
+
);
|
|
597
1290
|
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`);
|
|
598
1291
|
return;
|
|
599
1292
|
}
|
|
@@ -616,20 +1309,26 @@ 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.");
|
|
@@ -641,12 +1340,95 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
641
1340
|
orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
|
|
642
1341
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
643
1342
|
orchestrator.pendingSubagentSpawns > 0 ||
|
|
644
|
-
orchestrator.
|
|
1343
|
+
orchestrator.transitionController.isTransitioning()
|
|
645
1344
|
) return;
|
|
646
1345
|
|
|
647
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
|
+
|
|
648
1429
|
if (
|
|
649
1430
|
failedReviewerVariants.length > 0 &&
|
|
1431
|
+
effectiveMode !== "autonomous" &&
|
|
650
1432
|
!orchestrator.reviewerFailureDialogPending &&
|
|
651
1433
|
orchestrator.lastCtx
|
|
652
1434
|
) {
|
|
@@ -664,13 +1446,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
664
1446
|
const cycle = orchestrator.active?.state.reviewCycle;
|
|
665
1447
|
if (cycle) {
|
|
666
1448
|
const pass = cycle.pass;
|
|
667
|
-
const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
|
|
668
1449
|
const phase = orchestrator.active!.state.phase;
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
: phase === "plan"
|
|
672
|
-
? reviewConfig.planReviewers
|
|
673
|
-
: reviewConfig.codeReviewers;
|
|
1450
|
+
const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
1451
|
+
const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
674
1452
|
const failedSet = new Set(failedReviewerVariants);
|
|
675
1453
|
const scopedReviewers: typeof sourceReviewers = {};
|
|
676
1454
|
for (const [name, cfg] of Object.entries(sourceReviewers)) {
|
|
@@ -678,33 +1456,53 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
678
1456
|
}
|
|
679
1457
|
const retryCount = Object.keys(scopedReviewers).length;
|
|
680
1458
|
if (retryCount > 0) {
|
|
681
|
-
const retryConfig = phase === "brainstorm"
|
|
682
|
-
? { ...reviewConfig, brainstormReviewers: scopedReviewers }
|
|
683
|
-
: phase === "plan"
|
|
684
|
-
? { ...reviewConfig, planReviewers: scopedReviewers }
|
|
685
|
-
: { ...reviewConfig, codeReviewers: scopedReviewers };
|
|
686
1459
|
const spawnFn = phase === "brainstorm"
|
|
687
|
-
? () => 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
|
+
)
|
|
688
1471
|
: phase === "plan"
|
|
689
|
-
? () => spawnPlanReviewers(
|
|
690
|
-
|
|
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
|
+
);
|
|
691
1495
|
orchestrator.failedReviewerVariants = [];
|
|
692
1496
|
orchestrator.pendingSubagentSpawns = retryCount;
|
|
693
1497
|
cycle.step = "await_reviewers";
|
|
694
1498
|
orchestrator.active!.state.step = "await_reviewers";
|
|
695
1499
|
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
696
|
-
spawnFn()
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
703
|
-
checkReviewCycleCompletion();
|
|
704
|
-
}).catch((err) => {
|
|
705
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
706
|
-
console.error(`[pi-pi] retry spawn reviewers failed (${phase}): ${err.message}`);
|
|
707
|
-
checkReviewCycleCompletion();
|
|
1500
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
1501
|
+
kind: "reviewer",
|
|
1502
|
+
logScope: "review",
|
|
1503
|
+
logMessage: "retry spawn reviewers failed",
|
|
1504
|
+
logExtra: { phase },
|
|
1505
|
+
onSettled: checkReviewCycleCompletion,
|
|
708
1506
|
});
|
|
709
1507
|
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`);
|
|
710
1508
|
return;
|
|
@@ -736,6 +1534,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
736
1534
|
return;
|
|
737
1535
|
}
|
|
738
1536
|
|
|
1537
|
+
if (orchestrator.active.state.reviewerFailureAutoRetried) {
|
|
1538
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
1539
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1540
|
+
}
|
|
1541
|
+
markAllAgentsConsumed();
|
|
739
1542
|
tryCompleteReviewCycle(orchestrator);
|
|
740
1543
|
}
|
|
741
1544
|
|
|
@@ -764,13 +1567,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
764
1567
|
const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
|
|
765
1568
|
const stats = [duration, tokens].filter(Boolean).join(", ");
|
|
766
1569
|
|
|
767
|
-
|
|
1570
|
+
orchestrator.transitionController.sendCustom(
|
|
768
1571
|
{
|
|
769
1572
|
customType: "pp-subagent-result",
|
|
770
1573
|
content: `${desc} completed${stats ? ` (${stats})` : ""}. Use get_subagent_result to read the output.`,
|
|
771
1574
|
display: false,
|
|
772
1575
|
},
|
|
773
|
-
|
|
1576
|
+
"context",
|
|
774
1577
|
);
|
|
775
1578
|
|
|
776
1579
|
checkPlannerCompletion();
|
|
@@ -797,7 +1600,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
797
1600
|
orchestrator.abortAllSubagents();
|
|
798
1601
|
}
|
|
799
1602
|
|
|
800
|
-
|
|
1603
|
+
orchestrator.transitionController.sendCustom(
|
|
801
1604
|
{
|
|
802
1605
|
customType: "pp-subagent-error",
|
|
803
1606
|
content: isApiError
|
|
@@ -805,7 +1608,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
805
1608
|
: `**${desc}** failed: ${data.error || "unknown error"}. Do NOT retry — continue with available information.`,
|
|
806
1609
|
display: true,
|
|
807
1610
|
},
|
|
808
|
-
|
|
1611
|
+
"context",
|
|
809
1612
|
);
|
|
810
1613
|
|
|
811
1614
|
checkPlannerCompletion();
|
|
@@ -813,6 +1616,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
813
1616
|
});
|
|
814
1617
|
|
|
815
1618
|
pi.on("session_before_switch" as any, async () => {
|
|
1619
|
+
finalizeTracer();
|
|
816
1620
|
if (!orchestrator.active) return;
|
|
817
1621
|
cancelPendingPlannotatorWait(orchestrator);
|
|
818
1622
|
orchestrator.abortAllSubagents();
|
|
@@ -828,14 +1632,47 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
828
1632
|
try {
|
|
829
1633
|
dumpUsageSummary(tracker, sessionId);
|
|
830
1634
|
} catch (err: any) {
|
|
831
|
-
|
|
1635
|
+
getLogger().error({ s: "usage", err: err.message }, "failed to dump usage summary");
|
|
832
1636
|
}
|
|
1637
|
+
flushLogs();
|
|
1638
|
+
finalizeTracer();
|
|
833
1639
|
delete (globalThis as any)[USAGE_TRACKER_KEY];
|
|
834
1640
|
});
|
|
835
1641
|
|
|
836
1642
|
pi.on("session_start", async (_event, ctx) => {
|
|
837
1643
|
orchestrator.lastCtx = ctx;
|
|
838
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
|
+
}
|
|
839
1676
|
|
|
840
1677
|
if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
841
1678
|
const tracker = createUsageTracker();
|
|
@@ -865,7 +1702,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
865
1702
|
`Duplicate tools detected: ${duplicates.join(", ")}. ` +
|
|
866
1703
|
`Remove the conflicting packages: pi remove npm:@tintinweb/pi-subagents npm:@tintinweb/pi-tasks npm:pi-ask-user`;
|
|
867
1704
|
ctx.ui.notify(msg, "error");
|
|
868
|
-
|
|
1705
|
+
getLogger().error({ s: "init", duplicates }, "conflicting extensions detected");
|
|
869
1706
|
return;
|
|
870
1707
|
}
|
|
871
1708
|
|
|
@@ -874,16 +1711,28 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
874
1711
|
setPI(pi);
|
|
875
1712
|
await initFlantOnStartup(pi);
|
|
876
1713
|
} catch (err: any) {
|
|
877
|
-
|
|
1714
|
+
getLogger().error({ s: "flant", err: err.message }, "flant infra init failed");
|
|
878
1715
|
}
|
|
879
1716
|
|
|
880
1717
|
try {
|
|
881
1718
|
orchestrator.config = loadConfig(orchestrator.cwd);
|
|
882
1719
|
} catch (err: any) {
|
|
883
|
-
|
|
1720
|
+
getLogger().error({ s: "config", err: err.message }, "failed to load config on session start");
|
|
884
1721
|
return;
|
|
885
1722
|
}
|
|
886
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);
|
|
887
1736
|
registerCbmTools(pi, orchestrator.cwd);
|
|
888
1737
|
registerExaTools(pi);
|
|
889
1738
|
registerAstSearchTool(pi, orchestrator.cwd);
|
|
@@ -891,7 +1740,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
891
1740
|
setExtensionOnlyMode(pi);
|
|
892
1741
|
orchestrator.registerAgents();
|
|
893
1742
|
|
|
894
|
-
const found = getActiveTask(orchestrator.cwd, orchestrator.config.
|
|
1743
|
+
const found = getActiveTask(orchestrator.cwd, orchestrator.config.performance.internals.taskLockStale);
|
|
895
1744
|
if (found) {
|
|
896
1745
|
ctx.ui.notify(
|
|
897
1746
|
`Paused task: "${taskName(found.dir)}" (${found.type}, phase: ${found.state.phase}). Run /pp and choose Resume to continue.`,
|
|
@@ -913,34 +1762,77 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
913
1762
|
|
|
914
1763
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
915
1764
|
orchestrator.lastCtx = ctx;
|
|
916
|
-
|
|
917
|
-
|
|
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())) {
|
|
918
1770
|
return;
|
|
919
1771
|
}
|
|
920
1772
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
921
1773
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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;
|
|
926
1783
|
}
|
|
927
|
-
|
|
928
|
-
orchestrator.nudgeHalted = false;
|
|
929
1784
|
orchestrator.updateStatus(ctx);
|
|
930
1785
|
|
|
931
1786
|
const phasePrompt = orchestrator.getPhasePrompt(ctx);
|
|
932
|
-
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);
|
|
933
1793
|
const systemSnippets = systemContextFiles.map((f) => f.content).join("\n\n");
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
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");
|
|
937
1822
|
|
|
938
1823
|
return {
|
|
939
|
-
systemPrompt:
|
|
1824
|
+
systemPrompt: fullPrompt,
|
|
940
1825
|
};
|
|
941
1826
|
});
|
|
942
1827
|
|
|
943
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
|
+
|
|
944
1836
|
if (event.toolName === "Agent" && orchestrator.active) {
|
|
945
1837
|
const input = event.input as Record<string, unknown>;
|
|
946
1838
|
const requestedType = ((input.subagent_type as string) || "").toLowerCase();
|
|
@@ -952,16 +1844,16 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
952
1844
|
|
|
953
1845
|
if (isExplore) {
|
|
954
1846
|
input.subagent_type = "explore";
|
|
955
|
-
input.model = orchestrator.config.agents.explore.model;
|
|
956
|
-
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;
|
|
957
1849
|
} else if (isLibrarian) {
|
|
958
1850
|
input.subagent_type = "librarian";
|
|
959
|
-
input.model = orchestrator.config.agents.librarian.model;
|
|
960
|
-
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;
|
|
961
1853
|
} else {
|
|
962
1854
|
input.subagent_type = "task";
|
|
963
|
-
input.model = orchestrator.config.agents.task.model;
|
|
964
|
-
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;
|
|
965
1857
|
}
|
|
966
1858
|
}
|
|
967
1859
|
|
|
@@ -972,25 +1864,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
972
1864
|
const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
|
|
973
1865
|
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
974
1866
|
|
|
975
|
-
if (
|
|
1867
|
+
if (isPathInside(ppStateDir, resolvedPath)) {
|
|
976
1868
|
if (!resolvedPath.endsWith(".md")) {
|
|
977
1869
|
return { block: true, reason: "Cannot write non-.md files in .pp/state/" };
|
|
978
1870
|
}
|
|
979
1871
|
}
|
|
980
1872
|
|
|
981
1873
|
const fileName = basename(resolvedPath);
|
|
982
|
-
if (fileName === "state.json" && (
|
|
1874
|
+
if (fileName === "state.json" && isPathInside(ppDir, resolvedPath)) {
|
|
983
1875
|
return { block: true, reason: "state.json is managed by the extension" };
|
|
984
1876
|
}
|
|
985
1877
|
|
|
986
|
-
if (fileName === "config.json" && (
|
|
1878
|
+
if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
|
|
987
1879
|
return { block: true, reason: "config.json is managed by the user, not the LLM" };
|
|
988
1880
|
}
|
|
989
1881
|
}
|
|
990
1882
|
return;
|
|
991
1883
|
});
|
|
992
1884
|
|
|
993
|
-
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
|
+
|
|
994
1899
|
if (!orchestrator.active) return;
|
|
995
1900
|
|
|
996
1901
|
if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
|
|
@@ -1025,7 +1930,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1025
1930
|
}
|
|
1026
1931
|
}
|
|
1027
1932
|
const artifactsDir = join(taskDir, "artifacts");
|
|
1028
|
-
if (
|
|
1933
|
+
if (isPathInside(artifactsDir, resolvedWrite) && resolvedWrite.endsWith(".md")) {
|
|
1029
1934
|
const content = readFileSync(resolvedWrite, "utf-8");
|
|
1030
1935
|
const result = validateArtifact(content);
|
|
1031
1936
|
if (!result.ok) {
|
|
@@ -1038,24 +1943,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1038
1943
|
}
|
|
1039
1944
|
}
|
|
1040
1945
|
|
|
1041
|
-
|
|
1946
|
+
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
1947
|
+
if (isPathInside(ppDir, resolvedWrite)) return;
|
|
1042
1948
|
|
|
1043
1949
|
if (orchestrator.active.state.phase !== "implement") return;
|
|
1044
1950
|
|
|
1045
|
-
orchestrator.active.modifiedFiles.add(
|
|
1951
|
+
orchestrator.active.modifiedFiles.add(resolvedWrite);
|
|
1046
1952
|
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
1047
|
-
|
|
1048
|
-
const dir = resolve(filePath, "..");
|
|
1049
|
-
try {
|
|
1050
|
-
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
|
|
1051
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
1052
|
-
orchestrator.active.state.repoCwd = result.stdout.trim();
|
|
1053
|
-
}
|
|
1054
|
-
} catch {}
|
|
1055
|
-
}
|
|
1953
|
+
orchestrator.active.state.reviewApprovedClean = false;
|
|
1056
1954
|
try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
|
|
1057
1955
|
|
|
1058
|
-
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
|
+
}
|
|
1059
1979
|
const failures = afterEditResults.filter((r) => !r.ok);
|
|
1060
1980
|
|
|
1061
1981
|
if (failures.length > 0) {
|
|
@@ -1084,10 +2004,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1084
2004
|
});
|
|
1085
2005
|
|
|
1086
2006
|
pi.on("session_before_compact", async (event, _ctx) => {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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.";
|
|
1091
2014
|
return {
|
|
1092
2015
|
compaction: {
|
|
1093
2016
|
summary,
|
|
@@ -1097,20 +2020,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1097
2020
|
};
|
|
1098
2021
|
}
|
|
1099
2022
|
|
|
2023
|
+
// Natural (user-triggered) compaction: re-inject phase artifacts so context
|
|
2024
|
+
// survives.
|
|
1100
2025
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1101
2026
|
|
|
1102
|
-
if (orchestrator.phaseCompactionPending) {
|
|
1103
|
-
const summary = orchestrator.phaseCompactionSummary || "Phase transition in progress.";
|
|
1104
|
-
orchestrator.phaseCompactionSummary = "";
|
|
1105
|
-
return {
|
|
1106
|
-
compaction: {
|
|
1107
|
-
summary,
|
|
1108
|
-
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
1109
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
1110
|
-
},
|
|
1111
|
-
};
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
2027
|
const artifacts = getPhaseArtifacts(orchestrator.active.dir, orchestrator.active.state.phase);
|
|
1115
2028
|
if (artifacts.length === 0) return;
|
|
1116
2029
|
|
|
@@ -1118,13 +2031,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1118
2031
|
.map((a) => `=== ${a.name} ===\n${a.content}`)
|
|
1119
2032
|
.join("\n\n");
|
|
1120
2033
|
|
|
1121
|
-
|
|
2034
|
+
orchestrator.transitionController.sendCustom(
|
|
1122
2035
|
{
|
|
1123
2036
|
customType: "pp-artifact-reinject",
|
|
1124
2037
|
content: `[PI-PI ARTIFACTS — re-injected after compaction]\n\n${artifactText}`,
|
|
1125
2038
|
display: false,
|
|
1126
2039
|
},
|
|
1127
|
-
|
|
2040
|
+
"context",
|
|
1128
2041
|
);
|
|
1129
2042
|
|
|
1130
2043
|
return;
|
|
@@ -1136,33 +2049,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1136
2049
|
if (usageTracker && msg?.usage) {
|
|
1137
2050
|
const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
|
|
1138
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";
|
|
1139
2053
|
const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
|
|
1140
2054
|
const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
|
|
1141
2055
|
const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
|
|
1142
2056
|
const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
|
|
1143
2057
|
const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
|
|
1144
|
-
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
|
|
2058
|
+
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost, cacheSupported);
|
|
1145
2059
|
(ctx.ui as any)?.requestRender?.();
|
|
1146
2060
|
}
|
|
1147
2061
|
|
|
1148
2062
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1149
|
-
|
|
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;
|
|
1150
2066
|
orchestrator.updateStatus(ctx);
|
|
1151
2067
|
|
|
1152
2068
|
if (
|
|
1153
2069
|
orchestrator.active.state.phase === "implement" &&
|
|
1154
|
-
orchestrator.config.autoCommit &&
|
|
2070
|
+
orchestrator.config.general.autoCommit &&
|
|
1155
2071
|
orchestrator.active.modifiedFiles.size > 0 &&
|
|
1156
2072
|
!orchestrator.commitReminderSent
|
|
1157
2073
|
) {
|
|
1158
2074
|
orchestrator.commitReminderSent = true;
|
|
1159
|
-
|
|
2075
|
+
orchestrator.transitionController.sendCustom(
|
|
1160
2076
|
{
|
|
1161
2077
|
customType: "pp-commit-reminder",
|
|
1162
|
-
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.`,
|
|
1163
2079
|
display: false,
|
|
1164
2080
|
},
|
|
1165
|
-
|
|
2081
|
+
"context",
|
|
1166
2082
|
);
|
|
1167
2083
|
}
|
|
1168
2084
|
|
|
@@ -1171,20 +2087,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1171
2087
|
if (msg?.stopReason === "aborted") return;
|
|
1172
2088
|
if (msg?.stopReason === "error") {
|
|
1173
2089
|
const errorMsg = msg.errorMessage || "unknown error";
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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");
|
|
1183
2108
|
orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
|
|
1184
|
-
const
|
|
1185
|
-
if (orchestrator.errorRetryCount <=
|
|
1186
|
-
const delay =
|
|
1187
|
-
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");
|
|
1188
2113
|
const taskToken = orchestrator.activeTaskToken;
|
|
1189
2114
|
if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
|
|
1190
2115
|
orchestrator.pendingRetryTimer = setTimeout(() => {
|
|
@@ -1193,7 +2118,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1193
2118
|
orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
|
|
1194
2119
|
}, delay);
|
|
1195
2120
|
} else {
|
|
1196
|
-
ctx.ui.notify(`API error persisted after
|
|
2121
|
+
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
|
|
1197
2122
|
orchestrator.errorRetryCount = 0;
|
|
1198
2123
|
}
|
|
1199
2124
|
return;
|
|
@@ -1204,76 +2129,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1204
2129
|
return;
|
|
1205
2130
|
}
|
|
1206
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.
|
|
1207
2137
|
if (orchestrator.active.state.step === "await_planners" || orchestrator.active.state.step === "await_reviewers") {
|
|
1208
|
-
if (!orchestrator.awaitPollTimer) {
|
|
1209
|
-
orchestrator.awaitPollTimer = setInterval(() => {
|
|
1210
|
-
if (!orchestrator.active) {
|
|
1211
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1212
|
-
orchestrator.awaitPollTimer = null;
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
if (orchestrator.phaseCompactionPending) return;
|
|
1216
|
-
const taskDir = orchestrator.active.dir;
|
|
1217
|
-
if (orchestrator.active.state.step === "await_planners") {
|
|
1218
|
-
const plansDir = join(taskDir, "plans");
|
|
1219
|
-
const plannerCount = Object.values(orchestrator.config.planners).filter((v) => v.enabled).length;
|
|
1220
|
-
if (existsSync(plansDir)) {
|
|
1221
|
-
const planFiles = readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
|
|
1222
|
-
if (planFiles.length >= plannerCount) {
|
|
1223
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1224
|
-
orchestrator.awaitPollTimer = null;
|
|
1225
|
-
orchestrator.spawnedAgentIds.clear();
|
|
1226
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
1227
|
-
orchestrator.active.state.step = "synthesize";
|
|
1228
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1229
|
-
orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
} else if (orchestrator.active.state.step === "await_reviewers" && orchestrator.active.state.reviewCycle) {
|
|
1233
|
-
const cycle = orchestrator.active.state.reviewCycle;
|
|
1234
|
-
const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
|
|
1235
|
-
const phase = orchestrator.active.state.phase;
|
|
1236
|
-
const reviewers = phase === "brainstorm"
|
|
1237
|
-
? reviewConfig.brainstormReviewers
|
|
1238
|
-
: phase === "plan"
|
|
1239
|
-
? reviewConfig.planReviewers
|
|
1240
|
-
: reviewConfig.codeReviewers;
|
|
1241
|
-
const reviewerCount = Object.values(reviewers).filter((v) => v.enabled).length;
|
|
1242
|
-
const outputs = loadPhaseReviewOutputs(taskDir, phase, cycle.pass);
|
|
1243
|
-
if (outputs.length >= reviewerCount) {
|
|
1244
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1245
|
-
orchestrator.awaitPollTimer = null;
|
|
1246
|
-
orchestrator.spawnedAgentIds.clear();
|
|
1247
|
-
orchestrator.pendingSubagentSpawns = 0;
|
|
1248
|
-
|
|
1249
|
-
if (orchestrator.reviewTransitionToken === orchestrator.activeTaskToken) return;
|
|
1250
|
-
orchestrator.reviewTransitionToken = orchestrator.activeTaskToken;
|
|
1251
|
-
|
|
1252
|
-
cycle.step = "apply_feedback";
|
|
1253
|
-
orchestrator.active.state.step = "apply_feedback";
|
|
1254
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1255
|
-
const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
|
|
1256
|
-
pi.sendMessage(
|
|
1257
|
-
{ customType: "pp-review-ready", content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`, display: false },
|
|
1258
|
-
{ deliverAs: "followUp" },
|
|
1259
|
-
);
|
|
1260
|
-
orchestrator.safeSendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.");
|
|
1261
|
-
}
|
|
1262
|
-
} else {
|
|
1263
|
-
clearInterval(orchestrator.awaitPollTimer!);
|
|
1264
|
-
orchestrator.awaitPollTimer = null;
|
|
1265
|
-
}
|
|
1266
|
-
}, 5000);
|
|
1267
|
-
}
|
|
1268
2138
|
return;
|
|
1269
2139
|
}
|
|
1270
|
-
if (orchestrator.awaitPollTimer) {
|
|
1271
|
-
clearInterval(orchestrator.awaitPollTimer);
|
|
1272
|
-
orchestrator.awaitPollTimer = null;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
|
|
1276
|
-
if (orchestrator.active.type === "review" && phase === "review") return;
|
|
1277
2140
|
|
|
1278
2141
|
const contentParts = Array.isArray(msg?.content) ? msg.content : [];
|
|
1279
2142
|
const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
|
|
@@ -1281,78 +2144,51 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1281
2144
|
const hasToolResults = event.toolResults && event.toolResults.length > 0;
|
|
1282
2145
|
const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
|
|
1283
2146
|
|
|
1284
|
-
|
|
1285
|
-
const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
|
|
1286
|
-
const endsWithText = lastPart?.type === "text" && lastPart?.text?.trim();
|
|
1287
|
-
if (hasText && (!hasToolCalls || endsWithText)) {
|
|
1288
|
-
const step = orchestrator.active.state.step;
|
|
1289
|
-
if (step !== "await_planners" && step !== "await_reviewers" && !orchestrator.textStopReminderSent) {
|
|
1290
|
-
orchestrator.textStopReminderSent = true;
|
|
1291
|
-
orchestrator.safeSendUserMessage(`[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.`);
|
|
1292
|
-
}
|
|
1293
|
-
} else {
|
|
1294
|
-
orchestrator.textStopReminderSent = false;
|
|
1295
|
-
}
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
orchestrator.textStopReminderSent = false;
|
|
1299
|
-
if (orchestrator.nudgeHalted) return;
|
|
1300
|
-
if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) return;
|
|
1301
|
-
|
|
1302
|
-
const step = orchestrator.active.state.step;
|
|
1303
|
-
if (step === "await_planners" || step === "await_reviewers") return;
|
|
1304
|
-
|
|
1305
|
-
const now = Date.now();
|
|
2147
|
+
const isAutonomous = getEffectivePhaseMode(orchestrator.active.state) === "autonomous";
|
|
1306
2148
|
|
|
1307
|
-
|
|
1308
|
-
|
|
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();
|
|
1309
2154
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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));
|
|
1313
2161
|
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
2162
|
+
if (!isGenuineStop) {
|
|
2163
|
+
// Forward progress — clear the consecutive-nudge guard.
|
|
2164
|
+
orchestrator.consecutiveNudges = 0;
|
|
1316
2165
|
return;
|
|
1317
2166
|
}
|
|
1318
2167
|
|
|
1319
|
-
if (orchestrator.
|
|
1320
|
-
orchestrator.cooldownHits.push(now);
|
|
1321
|
-
orchestrator.cooldownHits = orchestrator.cooldownHits.filter((t) => now - t < 20 * 60 * 1000);
|
|
1322
|
-
|
|
1323
|
-
if (orchestrator.cooldownHits.length >= 5) {
|
|
1324
|
-
orchestrator.nudgeHalted = true;
|
|
1325
|
-
orchestrator.nudgeTimestamps = [];
|
|
1326
|
-
orchestrator.cooldownHits = [];
|
|
1327
|
-
pi.sendMessage(
|
|
1328
|
-
{
|
|
1329
|
-
customType: "pp-continuation-halted",
|
|
1330
|
-
content: "Agent has been repeatedly interrupted. Auto-continuation paused. Send any message to resume nudging.",
|
|
1331
|
-
display: true,
|
|
1332
|
-
},
|
|
1333
|
-
{ deliverAs: "steer" },
|
|
1334
|
-
);
|
|
1335
|
-
return;
|
|
1336
|
-
}
|
|
2168
|
+
if (!nudgesEnabled || orchestrator.nudgeHalted) return;
|
|
1337
2169
|
|
|
1338
|
-
|
|
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(
|
|
1339
2177
|
{
|
|
1340
|
-
customType: "pp-continuation-
|
|
1341
|
-
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.",
|
|
1342
2180
|
display: true,
|
|
1343
2181
|
},
|
|
1344
|
-
|
|
2182
|
+
"context",
|
|
1345
2183
|
);
|
|
1346
|
-
orchestrator.nudgeTimestamps = [];
|
|
1347
|
-
const cooldownToken = orchestrator.activeTaskToken;
|
|
1348
|
-
setTimeout(() => {
|
|
1349
|
-
if (orchestrator.activeTaskToken !== cooldownToken || !orchestrator.active) return;
|
|
1350
|
-
sendNudge();
|
|
1351
|
-
}, 60000);
|
|
1352
2184
|
return;
|
|
1353
2185
|
}
|
|
1354
2186
|
|
|
1355
|
-
|
|
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);
|
|
1356
2192
|
});
|
|
1357
2193
|
|
|
1358
2194
|
|