@ilya-lesikov/pi-pi 0.5.0 → 0.7.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 +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -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 +298 -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.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- 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 +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- 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 +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -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 +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- 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,67 @@ 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, getEffectivePhaseMode(orchestrator.active.state)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function reviewReadyMessage(phase: string, mode: TaskMode): 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
|
+
// Only autonomous plan/implement auto-advance: those must re-call
|
|
217
|
+
// pp_phase_complete to finalize the pass and transition. Guided phases
|
|
218
|
+
// (including debug and the interactive review phase) stay user-driven, so
|
|
219
|
+
// they get neutral wording with no re-call/auto-advance directive.
|
|
220
|
+
if (mode === "autonomous") {
|
|
221
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs, apply any required changes, then call pp_phase_complete again to finalize this review pass and advance the phase. Do NOT stop or wait for the user — the phase is NOT complete until you re-call pp_phase_complete.";
|
|
222
|
+
}
|
|
223
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs and apply any required changes.";
|
|
98
224
|
}
|
|
99
225
|
|
|
100
|
-
export async function enterReviewCycle(
|
|
226
|
+
export async function enterReviewCycle(
|
|
227
|
+
orchestrator: Orchestrator,
|
|
228
|
+
ctx: any,
|
|
229
|
+
kind: "plannotator" | string,
|
|
230
|
+
): Promise<string> {
|
|
101
231
|
if (!orchestrator.active) return "No active task.";
|
|
102
232
|
const pi = orchestrator.pi;
|
|
103
233
|
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
234
|
|
|
107
235
|
if (kind === "plannotator") {
|
|
236
|
+
orchestrator.active.state.reviewCycle = { kind: "plannotator", step: "spawn_reviewers", pass };
|
|
237
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
108
238
|
const phase = orchestrator.active.state.phase;
|
|
109
239
|
if (phase === "brainstorm") {
|
|
110
240
|
orchestrator.active.state.reviewCycle = null;
|
|
@@ -160,13 +290,14 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
|
|
|
160
290
|
}
|
|
161
291
|
|
|
162
292
|
const phase = orchestrator.active.state.phase;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
293
|
+
const presetName = kind || getDefaultReviewPresetName(orchestrator, phase);
|
|
294
|
+
orchestrator.active.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass };
|
|
295
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
296
|
+
orchestrator.active.state.activeReviewPreset = presetName;
|
|
297
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
298
|
+
|
|
299
|
+
const reviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
300
|
+
const enabledCount = Object.values(reviewers).filter((v) => isEnabled(v)).length;
|
|
170
301
|
if (enabledCount === 0) {
|
|
171
302
|
orchestrator.active.state.reviewCycle = null;
|
|
172
303
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
@@ -174,41 +305,77 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
|
|
|
174
305
|
return `No ${label} reviewers enabled. Choose another option.`;
|
|
175
306
|
}
|
|
176
307
|
|
|
177
|
-
orchestrator.reviewTransitionToken = -1;
|
|
178
308
|
orchestrator.pendingSubagentSpawns = enabledCount;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
309
|
+
const spawnFn = phase === "brainstorm"
|
|
310
|
+
? () => spawnBrainstormReviewers(
|
|
311
|
+
pi,
|
|
312
|
+
orchestrator.cwd,
|
|
313
|
+
orchestrator.active!.dir,
|
|
314
|
+
orchestrator.active!.taskId,
|
|
315
|
+
orchestrator.config,
|
|
316
|
+
pass,
|
|
317
|
+
orchestrator.transitionController.phaseSend,
|
|
318
|
+
reviewers,
|
|
319
|
+
orchestrator.active?.state.repos ?? [],
|
|
320
|
+
)
|
|
321
|
+
: phase === "plan"
|
|
322
|
+
? () => spawnPlanReviewers(
|
|
323
|
+
pi,
|
|
324
|
+
orchestrator.cwd,
|
|
325
|
+
orchestrator.active!.dir,
|
|
326
|
+
orchestrator.active!.taskId,
|
|
327
|
+
orchestrator.config,
|
|
328
|
+
pass,
|
|
329
|
+
orchestrator.transitionController.phaseSend,
|
|
330
|
+
reviewers,
|
|
331
|
+
orchestrator.active?.state.repos ?? [],
|
|
332
|
+
)
|
|
333
|
+
: () => spawnCodeReviewers(
|
|
334
|
+
pi,
|
|
335
|
+
orchestrator.cwd,
|
|
336
|
+
orchestrator.active!.dir,
|
|
337
|
+
orchestrator.active!.taskId,
|
|
338
|
+
orchestrator.config,
|
|
339
|
+
pass,
|
|
340
|
+
phase,
|
|
341
|
+
orchestrator.transitionController.phaseSend,
|
|
342
|
+
reviewers,
|
|
343
|
+
orchestrator.active?.state.repos ?? [],
|
|
344
|
+
);
|
|
345
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
346
|
+
kind: "reviewer",
|
|
347
|
+
logScope: "review",
|
|
348
|
+
logMessage: "spawn reviewers failed",
|
|
349
|
+
logExtra: { phase },
|
|
350
|
+
onSettled: (result) => tryCompleteReviewCycle(orchestrator, result?.spawned),
|
|
196
351
|
});
|
|
197
352
|
|
|
198
353
|
orchestrator.active.state.reviewCycle.step = "await_reviewers";
|
|
199
354
|
orchestrator.active.state.step = "await_reviewers";
|
|
200
355
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
201
|
-
return `Started review cycle pass ${pass} (${
|
|
356
|
+
return `Started review cycle pass ${pass} (auto, preset: ${presetName}). Awaiting reviewers.`;
|
|
202
357
|
}
|
|
203
358
|
|
|
204
359
|
export async function stopTask(orchestrator: Orchestrator): Promise<string> {
|
|
205
360
|
if (!orchestrator.active) return "No active task.";
|
|
206
361
|
orchestrator.abortAllSubagents();
|
|
362
|
+
orchestrator.active.state.reviewCycle = null;
|
|
207
363
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
208
364
|
const desc = orchestrator.active.description;
|
|
365
|
+
const type = orchestrator.active.type;
|
|
209
366
|
await orchestrator.cleanupActive();
|
|
210
367
|
const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
|
|
211
368
|
taskStore?.clearAll?.();
|
|
369
|
+
|
|
370
|
+
// Route the stop/pause compaction through the controller as a "done" target.
|
|
371
|
+
// The controller supplies the summary to session_before_compact and its
|
|
372
|
+
// awaitable resolves at every terminus (session_compact, no-op skip,
|
|
373
|
+
// already-idle), so this await never hangs and never resolves early.
|
|
374
|
+
await orchestrator.transitionController.requestTransition({
|
|
375
|
+
kind: "done",
|
|
376
|
+
summary: `Task "${desc}" (${type}) stopped/paused.`,
|
|
377
|
+
});
|
|
378
|
+
|
|
212
379
|
return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
|
|
213
380
|
}
|
|
214
381
|
|
|
@@ -217,19 +384,184 @@ export function finalizeReviewCycle(task: ActiveTask): void {
|
|
|
217
384
|
const kind = task.state.reviewCycle.kind;
|
|
218
385
|
task.state.reviewPass = task.state.reviewCycle.pass;
|
|
219
386
|
task.reviewPass = task.state.reviewPass;
|
|
220
|
-
|
|
221
|
-
task.state.reviewPassByKind[kind] = (task.state.reviewPassByKind[kind] ?? 0) + 1;
|
|
387
|
+
incrementReviewPass(task, kind);
|
|
222
388
|
task.state.reviewCycle = null;
|
|
223
389
|
task.state.step = "user_gate";
|
|
224
390
|
saveTask(task.dir, task.state);
|
|
225
391
|
}
|
|
226
392
|
|
|
393
|
+
function incrementReviewPass(task: ActiveTask, kind: string): void {
|
|
394
|
+
if (!task.state.reviewPassByKind) task.state.reviewPassByKind = {};
|
|
395
|
+
const phase = task.state.phase;
|
|
396
|
+
if (!task.state.reviewPassByKind[phase]) task.state.reviewPassByKind[phase] = {};
|
|
397
|
+
task.state.reviewPassByKind[phase][kind] = (task.state.reviewPassByKind[phase][kind] ?? 0) + 1;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function completedReviewPasses(task: ActiveTask, kind: string): number {
|
|
401
|
+
return task.state.reviewPassByKind?.[task.state.phase]?.[kind] ?? 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
|
|
405
|
+
if (!task.state.reviewCycle) return;
|
|
406
|
+
const kind = task.state.reviewCycle.kind;
|
|
407
|
+
task.state.reviewPass = task.state.reviewCycle.pass;
|
|
408
|
+
task.reviewPass = task.state.reviewPass;
|
|
409
|
+
incrementReviewPass(task, kind);
|
|
410
|
+
task.state.reviewCycle = null;
|
|
411
|
+
if (task.state.phase === "plan") {
|
|
412
|
+
task.state.step = "synthesize";
|
|
413
|
+
} else {
|
|
414
|
+
task.state.step = "llm_work";
|
|
415
|
+
}
|
|
416
|
+
saveTask(task.dir, task.state);
|
|
417
|
+
}
|
|
418
|
+
|
|
227
419
|
function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
420
|
+
registerRepoTool(orchestrator);
|
|
228
421
|
registerPhaseCompleteTool(orchestrator);
|
|
229
422
|
registerCommitTool(orchestrator);
|
|
230
423
|
registerSpecifyReviewsTool(orchestrator);
|
|
231
424
|
}
|
|
232
425
|
|
|
426
|
+
function registerRepoTool(orchestrator: Orchestrator): void {
|
|
427
|
+
const pi = orchestrator.pi;
|
|
428
|
+
|
|
429
|
+
pi.registerTool({
|
|
430
|
+
name: "pp_register_repo",
|
|
431
|
+
label: "pi-pi",
|
|
432
|
+
description:
|
|
433
|
+
"Register a git repository you're working in. Call this for every repo " +
|
|
434
|
+
"including the root directory at the start of each task. Pass the base " +
|
|
435
|
+
"branch — the branch this work will be merged into (e.g. origin/main, origin/develop).",
|
|
436
|
+
parameters: Type.Object({
|
|
437
|
+
path: Type.String({ description: "Absolute path to the git repository (or any path inside it)" }),
|
|
438
|
+
baseBranch: Type.Optional(Type.String({ description: "Base branch for this repo (e.g. origin/main)" })),
|
|
439
|
+
}),
|
|
440
|
+
async execute(_toolCallId, params: any) {
|
|
441
|
+
if (!orchestrator.active) {
|
|
442
|
+
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
443
|
+
}
|
|
444
|
+
getLogger().debug({ s: "tool", tool: "pp_register_repo", path: params.path, baseBranch: params.baseBranch }, "register repo called");
|
|
445
|
+
|
|
446
|
+
const pathInput = typeof params.path === "string" ? params.path.trim() : "";
|
|
447
|
+
if (!pathInput) {
|
|
448
|
+
return { content: [{ type: "text" as const, text: "Missing path." }], isError: true as const, details: {} };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const normalizedInputPath = normalizeRepoPath(pathInput);
|
|
452
|
+
let gitCwd = normalizedInputPath;
|
|
453
|
+
try {
|
|
454
|
+
gitCwd = statSync(normalizedInputPath).isDirectory() ? normalizedInputPath : dirname(normalizedInputPath);
|
|
455
|
+
} catch {
|
|
456
|
+
gitCwd = dirname(normalizedInputPath);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let gitRoot = "";
|
|
460
|
+
try {
|
|
461
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: gitCwd, timeout: 5000 });
|
|
462
|
+
if (result.code !== 0) {
|
|
463
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
464
|
+
}
|
|
465
|
+
gitRoot = result.stdout.trim();
|
|
466
|
+
} catch {
|
|
467
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!gitRoot) {
|
|
471
|
+
return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const normalizedRepo = normalizeRepoPath(gitRoot);
|
|
475
|
+
const normalizedRoot = normalizeRepoPath(orchestrator.cwd);
|
|
476
|
+
const hadRepos = Array.isArray(orchestrator.active.state.repos);
|
|
477
|
+
const repos = orchestrator.active.state.repos ?? [{ path: normalizedRoot, isRoot: true }];
|
|
478
|
+
const baseBranch = typeof params.baseBranch === "string" && params.baseBranch.trim().length > 0
|
|
479
|
+
? params.baseBranch.trim()
|
|
480
|
+
: undefined;
|
|
481
|
+
const isRoot = normalizedRepo === normalizedRoot;
|
|
482
|
+
|
|
483
|
+
let added = false;
|
|
484
|
+
let changed = !hadRepos;
|
|
485
|
+
|
|
486
|
+
if (isRoot) {
|
|
487
|
+
const existingRootIdx = repos.findIndex((repo) => repo.isRoot);
|
|
488
|
+
const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
|
|
489
|
+
|
|
490
|
+
if (existingRootIdx >= 0) {
|
|
491
|
+
const existingRoot = repos[existingRootIdx];
|
|
492
|
+
if (existingRoot.path !== normalizedRepo) {
|
|
493
|
+
existingRoot.path = normalizedRepo;
|
|
494
|
+
changed = true;
|
|
495
|
+
}
|
|
496
|
+
if (!existingRoot.isRoot) {
|
|
497
|
+
existingRoot.isRoot = true;
|
|
498
|
+
changed = true;
|
|
499
|
+
}
|
|
500
|
+
if (baseBranch && existingRoot.baseBranch !== baseBranch) {
|
|
501
|
+
existingRoot.baseBranch = baseBranch;
|
|
502
|
+
changed = true;
|
|
503
|
+
}
|
|
504
|
+
if (existingByPathIdx >= 0 && existingByPathIdx !== existingRootIdx) {
|
|
505
|
+
repos.splice(existingByPathIdx, 1);
|
|
506
|
+
changed = true;
|
|
507
|
+
}
|
|
508
|
+
} else if (existingByPathIdx >= 0) {
|
|
509
|
+
const existing = repos[existingByPathIdx];
|
|
510
|
+
if (!existing.isRoot) {
|
|
511
|
+
existing.isRoot = true;
|
|
512
|
+
changed = true;
|
|
513
|
+
}
|
|
514
|
+
if (baseBranch && existing.baseBranch !== baseBranch) {
|
|
515
|
+
existing.baseBranch = baseBranch;
|
|
516
|
+
changed = true;
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
repos.push({ path: normalizedRepo, isRoot: true, ...(baseBranch ? { baseBranch } : {}) });
|
|
520
|
+
added = true;
|
|
521
|
+
changed = true;
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
|
|
525
|
+
if (existingByPathIdx >= 0) {
|
|
526
|
+
const existing = repos[existingByPathIdx];
|
|
527
|
+
if (baseBranch && existing.baseBranch !== baseBranch) {
|
|
528
|
+
existing.baseBranch = baseBranch;
|
|
529
|
+
changed = true;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
repos.push({ path: normalizedRepo, isRoot: false, ...(baseBranch ? { baseBranch } : {}) });
|
|
533
|
+
added = true;
|
|
534
|
+
changed = true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
orchestrator.active.state.repos = repos;
|
|
539
|
+
if (changed) {
|
|
540
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (added) {
|
|
544
|
+
unregisterAgentDefinitions(orchestrator.pi);
|
|
545
|
+
orchestrator.registerAgents();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const registered = repos.find((repo) => repo.path === normalizedRepo) ?? {
|
|
549
|
+
path: normalizedRepo,
|
|
550
|
+
isRoot,
|
|
551
|
+
...(baseBranch ? { baseBranch } : {}),
|
|
552
|
+
};
|
|
553
|
+
const rootLabel = registered.isRoot ? " (root)" : "";
|
|
554
|
+
const baseLabel = registered.baseBranch ? `, base: ${registered.baseBranch}` : "";
|
|
555
|
+
const action = added ? "Registered" : changed ? "Updated" : "Already registered";
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
content: [{ type: "text" as const, text: `${action} repository: ${registered.path}${rootLabel}${baseLabel}` }],
|
|
559
|
+
details: {},
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
233
565
|
function openCodeReviewDirect(
|
|
234
566
|
pi: ExtensionAPI,
|
|
235
567
|
payload: Record<string, unknown>,
|
|
@@ -259,7 +591,8 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
259
591
|
description:
|
|
260
592
|
"Specify which repositories and commit ranges to open in Plannotator for code review. " +
|
|
261
593
|
"Called when the user requests a Plannotator code review. " +
|
|
262
|
-
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete."
|
|
594
|
+
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
|
|
595
|
+
"Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
|
|
263
596
|
parameters: Type.Object({
|
|
264
597
|
reviews: Type.Array(Type.Object({
|
|
265
598
|
cwd: Type.String({ description: "Absolute path to the git repository" }),
|
|
@@ -267,6 +600,9 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
267
600
|
})),
|
|
268
601
|
}),
|
|
269
602
|
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
|
|
603
|
+
if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
|
|
604
|
+
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: {} };
|
|
605
|
+
}
|
|
270
606
|
if (!params.reviews || params.reviews.length === 0) {
|
|
271
607
|
return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
|
|
272
608
|
}
|
|
@@ -275,12 +611,89 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
275
611
|
let hasNeedsChanges = false;
|
|
276
612
|
for (const review of params.reviews) {
|
|
277
613
|
ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
|
|
278
|
-
const
|
|
614
|
+
const range = String(review.range ?? "").trim();
|
|
615
|
+
let compareBase = range;
|
|
616
|
+
let compareTarget = "HEAD";
|
|
617
|
+
const rangeSeparatorIdx = range.indexOf("..");
|
|
618
|
+
if (rangeSeparatorIdx >= 0) {
|
|
619
|
+
compareBase = range.slice(0, rangeSeparatorIdx).trim();
|
|
620
|
+
compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!compareBase) {
|
|
624
|
+
results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
let reviewCwd = review.cwd;
|
|
629
|
+
let tempWorktreePath: string | null = null;
|
|
630
|
+
let tempWorktreeParent: string | null = null;
|
|
631
|
+
let setupError: string | null = null;
|
|
632
|
+
|
|
633
|
+
if (compareTarget !== "HEAD") {
|
|
634
|
+
tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
|
|
635
|
+
tempWorktreePath = join(tempWorktreeParent, "checkout");
|
|
636
|
+
try {
|
|
637
|
+
const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
|
|
638
|
+
cwd: review.cwd,
|
|
639
|
+
timeout: 20000,
|
|
640
|
+
});
|
|
641
|
+
if (addResult.code !== 0) {
|
|
642
|
+
setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
|
|
643
|
+
} else {
|
|
644
|
+
reviewCwd = tempWorktreePath;
|
|
645
|
+
}
|
|
646
|
+
} catch (error: any) {
|
|
647
|
+
setupError = error?.message ?? String(error);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (setupError) {
|
|
652
|
+
results.push(`${review.cwd} (${review.range}): ${setupError}`);
|
|
653
|
+
if (tempWorktreePath) {
|
|
654
|
+
try {
|
|
655
|
+
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
656
|
+
cwd: review.cwd,
|
|
657
|
+
timeout: 20000,
|
|
658
|
+
});
|
|
659
|
+
} catch {}
|
|
660
|
+
}
|
|
661
|
+
if (tempWorktreePath) {
|
|
662
|
+
try {
|
|
663
|
+
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
664
|
+
} catch {}
|
|
665
|
+
}
|
|
666
|
+
if (tempWorktreeParent) {
|
|
667
|
+
try {
|
|
668
|
+
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
669
|
+
} catch {}
|
|
670
|
+
}
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
279
674
|
const result = await openCodeReviewDirect(pi, {
|
|
280
|
-
cwd:
|
|
675
|
+
cwd: reviewCwd,
|
|
281
676
|
diffType: "branch",
|
|
282
|
-
defaultBranch:
|
|
677
|
+
defaultBranch: compareBase,
|
|
283
678
|
});
|
|
679
|
+
|
|
680
|
+
if (tempWorktreePath) {
|
|
681
|
+
try {
|
|
682
|
+
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
683
|
+
cwd: review.cwd,
|
|
684
|
+
timeout: 20000,
|
|
685
|
+
});
|
|
686
|
+
} catch {}
|
|
687
|
+
try {
|
|
688
|
+
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
689
|
+
} catch {}
|
|
690
|
+
if (tempWorktreeParent) {
|
|
691
|
+
try {
|
|
692
|
+
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
693
|
+
} catch {}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
284
697
|
if ("error" in result) {
|
|
285
698
|
results.push(`${review.cwd} (${review.range}): ${result.error}`);
|
|
286
699
|
} else {
|
|
@@ -307,9 +720,18 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
307
720
|
|
|
308
721
|
ctx.ui?.setWorkingMessage?.("Waiting for user input…");
|
|
309
722
|
try {
|
|
310
|
-
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
723
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
311
724
|
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
312
|
-
|
|
725
|
+
// Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
|
|
726
|
+
// reminder text that would start a new LLM turn.
|
|
727
|
+
if (text === USER_CANCELLED) {
|
|
728
|
+
ctx.abort?.();
|
|
729
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
730
|
+
}
|
|
731
|
+
// A transition may have started while the menu was open. The controller
|
|
732
|
+
// is the source of truth; abort the agent's pending turn so it doesn't
|
|
733
|
+
// race the transition. (Interactive-UX abort — stays local, not routed.)
|
|
734
|
+
if (!orchestrator.transitionController.isRunning()) {
|
|
313
735
|
ctx.abort?.();
|
|
314
736
|
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
315
737
|
}
|
|
@@ -326,7 +748,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
326
748
|
|
|
327
749
|
function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
|
|
328
750
|
if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
|
|
329
|
-
if (phase === "plan") return loadPlanReviewOutputs(taskDir);
|
|
751
|
+
if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
|
|
330
752
|
return loadCodeReviewOutputs(taskDir, pass);
|
|
331
753
|
}
|
|
332
754
|
|
|
@@ -339,37 +761,65 @@ function registerCommitTool(orchestrator: Orchestrator): void {
|
|
|
339
761
|
description:
|
|
340
762
|
"Commit modified files with a descriptive message. Call after completing a logical " +
|
|
341
763
|
"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."
|
|
764
|
+
"The message should describe WHAT changed and WHY, not list files. " +
|
|
765
|
+
"Prefix the message with a conventional-commit type (fix:, feat:, or chore:) " +
|
|
766
|
+
"unless the user asked for a different commit style.",
|
|
343
767
|
parameters: Type.Object({
|
|
344
768
|
message: Type.String({ description: "Commit message describing the change (max 72 chars for first line)" }),
|
|
769
|
+
repo: Type.Optional(Type.String({ description: "Absolute path to the repo to commit in. Defaults to root." })),
|
|
345
770
|
}),
|
|
346
771
|
async execute(_toolCallId, params: any) {
|
|
347
772
|
if (!orchestrator.active) {
|
|
348
773
|
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
349
774
|
}
|
|
350
|
-
if (!orchestrator.config.autoCommit) {
|
|
775
|
+
if (!orchestrator.config.general.autoCommit) {
|
|
351
776
|
return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
|
|
352
777
|
}
|
|
778
|
+
|
|
779
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
780
|
+
const rootRepo = findRootRepo(repos);
|
|
781
|
+
const defaultRepoPath = rootRepo?.path ?? orchestrator.cwd;
|
|
782
|
+
let commitRepoPath = defaultRepoPath;
|
|
783
|
+
if (typeof params.repo === "string" && params.repo.trim().length > 0) {
|
|
784
|
+
const normalized = normalizeRepoPath(params.repo);
|
|
785
|
+
const registered = repos.find((repo) => repo.path === normalized);
|
|
786
|
+
if (!registered) {
|
|
787
|
+
return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repo}` }], isError: true as const, details: {} };
|
|
788
|
+
}
|
|
789
|
+
commitRepoPath = registered.path;
|
|
790
|
+
}
|
|
791
|
+
|
|
353
792
|
const files: string[] = [];
|
|
354
793
|
try {
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
794
|
+
const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: commitRepoPath, timeout: 5000 });
|
|
795
|
+
if (statusResult.code === 0 && statusResult.stdout.trim()) {
|
|
796
|
+
for (const rawLine of statusResult.stdout.split("\n")) {
|
|
797
|
+
const line = rawLine.trimEnd();
|
|
798
|
+
if (!line) continue;
|
|
799
|
+
const pathPart = line.slice(3);
|
|
800
|
+
if (!pathPart) continue;
|
|
801
|
+
const finalPath = pathPart.includes(" -> ") ? pathPart.split(" -> ").at(-1)! : pathPart;
|
|
802
|
+
if (!files.includes(finalPath)) {
|
|
803
|
+
files.push(finalPath);
|
|
804
|
+
}
|
|
363
805
|
}
|
|
364
806
|
}
|
|
365
807
|
} catch {}
|
|
366
808
|
if (files.length === 0) {
|
|
367
809
|
return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
|
|
368
810
|
}
|
|
369
|
-
const result = autoCommit(files, params.message,
|
|
811
|
+
const result = autoCommit(files, params.message, commitRepoPath);
|
|
370
812
|
if (result.ok) {
|
|
371
|
-
orchestrator.active.modifiedFiles.
|
|
372
|
-
|
|
813
|
+
const remaining = [...orchestrator.active.modifiedFiles].filter((file) => {
|
|
814
|
+
const absoluteFile = resolve(orchestrator.cwd, file);
|
|
815
|
+
const repo = resolveRepoForFile(repos, absoluteFile);
|
|
816
|
+
return repo?.path !== commitRepoPath;
|
|
817
|
+
});
|
|
818
|
+
orchestrator.active.modifiedFiles = new Set(remaining);
|
|
819
|
+
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
820
|
+
const committed = new Set(orchestrator.active.state.committedFiles ?? []);
|
|
821
|
+
for (const file of files) committed.add(file);
|
|
822
|
+
orchestrator.active.state.committedFiles = [...committed];
|
|
373
823
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
374
824
|
orchestrator.commitReminderSent = false;
|
|
375
825
|
return { content: [{ type: "text" as const, text: `Committed ${files.length} file(s): ${result.commitHash ?? "ok"}` }], details: {} };
|
|
@@ -403,15 +853,102 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
403
853
|
const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
|
|
404
854
|
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
855
|
}
|
|
856
|
+
// Gate on the effective PHASE mode, not the task mode: brainstorm/debug/
|
|
857
|
+
// review are always user-driven even for an autonomous task, so they must
|
|
858
|
+
// fall through to the guided menu path below rather than auto-advancing.
|
|
859
|
+
const effectiveMode = getEffectivePhaseMode(orchestrator.active.state);
|
|
860
|
+
if (effectiveMode === "autonomous") {
|
|
861
|
+
const phase = orchestrator.active.state.phase;
|
|
862
|
+
let justFinalizedReviewCycle = false;
|
|
863
|
+
if (orchestrator.active.state.reviewCycle?.step === "apply_feedback") {
|
|
864
|
+
const completedRound = orchestrator.active.state.reviewCycle.pass;
|
|
865
|
+
const completedPreset = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
866
|
+
const enabledReviewerCount = Object.values(resolveReviewers(orchestrator, phase, completedPreset)).filter((v) => isEnabled(v)).length;
|
|
867
|
+
finalizeReviewCycleAutonomous(orchestrator.active);
|
|
868
|
+
justFinalizedReviewCycle = true;
|
|
869
|
+
if (reviewPassUnanimousApprove(orchestrator.active.dir, phase, completedRound, enabledReviewerCount)) {
|
|
870
|
+
orchestrator.active.state.reviewApprovedClean = true;
|
|
871
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const phaseConfig = orchestrator.active.state.autonomousConfig?.phases?.[phase];
|
|
875
|
+
const reviewPreset = phaseConfig?.reviewPreset;
|
|
876
|
+
const maxReviewPasses = phaseConfig?.maxReviewPasses ?? 0;
|
|
877
|
+
const completedAutoPasses = orchestrator.active.state.reviewPassByKind?.[phase]?.auto ?? 0;
|
|
878
|
+
|
|
879
|
+
if (
|
|
880
|
+
justFinalizedReviewCycle &&
|
|
881
|
+
!orchestrator.active.state.reviewApprovedClean &&
|
|
882
|
+
reviewPreset &&
|
|
883
|
+
maxReviewPasses > 0 &&
|
|
884
|
+
completedAutoPasses < maxReviewPasses
|
|
885
|
+
) {
|
|
886
|
+
return {
|
|
887
|
+
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.` }],
|
|
888
|
+
details: {},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (
|
|
893
|
+
!justFinalizedReviewCycle &&
|
|
894
|
+
!orchestrator.active.state.reviewApprovedClean &&
|
|
895
|
+
reviewPreset &&
|
|
896
|
+
maxReviewPasses > 0 &&
|
|
897
|
+
completedAutoPasses < maxReviewPasses
|
|
898
|
+
) {
|
|
899
|
+
const exitCheck = validateExitCriteria(orchestrator.active.dir, orchestrator.active.type, phase);
|
|
900
|
+
if (!exitCheck.ok) {
|
|
901
|
+
return {
|
|
902
|
+
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.` }],
|
|
903
|
+
details: {},
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
const reviewText = await enterReviewCycle(orchestrator, ctx, reviewPreset);
|
|
907
|
+
if (orchestrator.active?.state.step === "await_reviewers") {
|
|
908
|
+
return {
|
|
909
|
+
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.` }],
|
|
910
|
+
details: {},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
if (!reviewText.includes("No") || !reviewText.includes("reviewers enabled")) {
|
|
914
|
+
return { content: [{ type: "text" as const, text: reviewText }], details: {} };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
|
|
919
|
+
const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
|
|
920
|
+
if (!result.ok) {
|
|
921
|
+
return { content: [{ type: "text" as const, text: `Transition blocked: ${result.error}` }], details: {} };
|
|
922
|
+
}
|
|
923
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
924
|
+
}
|
|
406
925
|
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
407
926
|
try {
|
|
408
|
-
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
927
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
409
928
|
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
|
|
410
|
-
|
|
929
|
+
// A deliberate user ESC on the menu means "stop cleanly, let me type" —
|
|
930
|
+
// mirror ask_user: abort the turn and return nothing so no new LLM turn
|
|
931
|
+
// starts. This takes precedence over the reminder path below, in ALL
|
|
932
|
+
// interactive cases (including while a transition is mid-flight).
|
|
933
|
+
if (text === USER_CANCELLED) {
|
|
934
|
+
ctx.abort?.();
|
|
935
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
936
|
+
}
|
|
937
|
+
// A transition or await may have started while the menu was open. The
|
|
938
|
+
// controller is the source of truth; abort the pending turn so it can't
|
|
939
|
+
// race. (Interactive-UX abort — stays local, not routed.)
|
|
940
|
+
const curStep = orchestrator.active?.state.step;
|
|
941
|
+
if (curStep === "await_planners" || curStep === "await_reviewers") {
|
|
942
|
+
ctx.abort?.();
|
|
943
|
+
return { content: [{ type: "text" as const, text: `Waiting for ${curStep === "await_planners" ? "planners" : "reviewers"} to complete. Do NOT proceed until notified.` }], details: {} };
|
|
944
|
+
}
|
|
945
|
+
if (!orchestrator.transitionController.isRunning()) {
|
|
411
946
|
ctx.abort?.();
|
|
412
947
|
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
413
948
|
}
|
|
414
949
|
if (!text) {
|
|
950
|
+
// Non-ESC dismissal (explicit "Back") while a transition is running:
|
|
951
|
+
// keep the intentional artifact-update reminder (commit 10e7021).
|
|
415
952
|
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
416
953
|
}
|
|
417
954
|
return { content: [{ type: "text" as const, text }], details: {} };
|
|
@@ -422,9 +959,104 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
422
959
|
});
|
|
423
960
|
}
|
|
424
961
|
|
|
962
|
+
function registerMainTraceHooks(orchestrator: Orchestrator): void {
|
|
963
|
+
const pi = orchestrator.pi;
|
|
964
|
+
// These hooks are registered only on the root orchestrator session
|
|
965
|
+
// (registerEventHandlers is never called from the subagent branch in index.ts),
|
|
966
|
+
// so they only ever receive root-session events — no SUBAGENT_SESSION_KEY gate needed.
|
|
967
|
+
|
|
968
|
+
pi.on("before_agent_start", async (event) => {
|
|
969
|
+
getTracer()?.traceMain("before_agent_start", {
|
|
970
|
+
prompt: event.prompt,
|
|
971
|
+
images: event.images,
|
|
972
|
+
systemPrompt: event.systemPrompt,
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
pi.on("agent_start", async () => {
|
|
976
|
+
getTracer()?.traceMain("agent_start", {});
|
|
977
|
+
});
|
|
978
|
+
pi.on("agent_end", async (event) => {
|
|
979
|
+
getTracer()?.traceMain("agent_end", { messages: event.messages });
|
|
980
|
+
});
|
|
981
|
+
pi.on("turn_start", async (event) => {
|
|
982
|
+
const tracer = getTracer();
|
|
983
|
+
if (tracer) tracer.turnIndex = event.turnIndex;
|
|
984
|
+
tracer?.traceMain("turn_start", { turnIndex: event.turnIndex, timestamp: event.timestamp });
|
|
985
|
+
});
|
|
986
|
+
pi.on("turn_end", async (event) => {
|
|
987
|
+
const tracer = getTracer();
|
|
988
|
+
if (tracer) tracer.turnIndex = event.turnIndex;
|
|
989
|
+
tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
|
|
990
|
+
});
|
|
991
|
+
pi.on("message_start", async (event) => {
|
|
992
|
+
const tracer = getTracer();
|
|
993
|
+
tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
|
|
994
|
+
});
|
|
995
|
+
pi.on("message_update", async (event) => {
|
|
996
|
+
const tracer = getTracer();
|
|
997
|
+
tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
|
|
998
|
+
});
|
|
999
|
+
pi.on("message_end", async (event) => {
|
|
1000
|
+
const tracer = getTracer();
|
|
1001
|
+
tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
|
|
1002
|
+
});
|
|
1003
|
+
pi.on("tool_execution_start", async (event) => {
|
|
1004
|
+
const tracer = getTracer();
|
|
1005
|
+
tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
|
|
1006
|
+
});
|
|
1007
|
+
pi.on("tool_execution_update", async (event) => {
|
|
1008
|
+
const tracer = getTracer();
|
|
1009
|
+
tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
|
|
1010
|
+
});
|
|
1011
|
+
pi.on("tool_execution_end", async (event) => {
|
|
1012
|
+
const tracer = getTracer();
|
|
1013
|
+
tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
425
1017
|
export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
426
1018
|
const pi = orchestrator.pi;
|
|
427
1019
|
|
|
1020
|
+
registerMainTraceHooks(orchestrator);
|
|
1021
|
+
|
|
1022
|
+
// Personal-subscription Claude routing registers the sub provider with a
|
|
1023
|
+
// literal OAuth token (a static snapshot). That token expires within a few
|
|
1024
|
+
// hours, so a long-lived session would keep sending a stale token and the
|
|
1025
|
+
// gateway would return 401. Refresh (and re-register on change) before each
|
|
1026
|
+
// turn's LLM call. Cheap no-op when subscription routing is inactive or the
|
|
1027
|
+
// token is unchanged. registerEventHandlers runs only on the root session.
|
|
1028
|
+
pi.on("turn_start", async () => {
|
|
1029
|
+
try {
|
|
1030
|
+
const { refreshSubProvider } = await import("./flant-infra.js");
|
|
1031
|
+
await refreshSubProvider(pi);
|
|
1032
|
+
} catch (err: any) {
|
|
1033
|
+
getLogger().debug({ s: "flant", err: err?.message }, "sub provider refresh failed");
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// TransitionController drivers. These are the reliable idle/completion signals:
|
|
1038
|
+
// - agent_end: the main loop went idle. If a transition is pending, this is
|
|
1039
|
+
// when the controller fires compaction.
|
|
1040
|
+
// - session_compact: compaction finished. The controller resumes (injects
|
|
1041
|
+
// context/artifacts + sends the next instruction).
|
|
1042
|
+
// The SDK supports multiple handlers per event, so these coexist with the
|
|
1043
|
+
// trace-only hooks registered above. registerEventHandlers is never called on
|
|
1044
|
+
// the subagent branch, so these only ever see root-session events.
|
|
1045
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1046
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1047
|
+
orchestrator.transitionController.onAgentEnd();
|
|
1048
|
+
});
|
|
1049
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
1050
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1051
|
+
orchestrator.transitionController.onSessionCompact();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Expose the event-driven planner-completion check so initial plan-entry
|
|
1055
|
+
// spawns (in command-handlers / orchestrator) can wire it as their onSettled
|
|
1056
|
+
// safety net (the deleted poller's former role). checkPlannerCompletion is a
|
|
1057
|
+
// hoisted function declaration below.
|
|
1058
|
+
orchestrator.checkPlannerCompletion = () => checkPlannerCompletion();
|
|
1059
|
+
|
|
428
1060
|
function getUsageTracker(): UsageTracker | undefined {
|
|
429
1061
|
return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
|
|
430
1062
|
}
|
|
@@ -444,6 +1076,25 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
444
1076
|
if (event === "first_turn" && lifecycle.firstTurnAt == null) lifecycle.firstTurnAt = now;
|
|
445
1077
|
orchestrator.agentLifecycle.set(data.id, lifecycle);
|
|
446
1078
|
|
|
1079
|
+
getTracer()?.traceMain("subagent_lifecycle", {
|
|
1080
|
+
event,
|
|
1081
|
+
subagentId: data.id,
|
|
1082
|
+
type: data.type ?? lifecycle.type,
|
|
1083
|
+
description: data.description ?? lifecycle.description,
|
|
1084
|
+
parentToolCallId: data.toolCallId,
|
|
1085
|
+
phase: lifecycle.phase,
|
|
1086
|
+
step: lifecycle.step,
|
|
1087
|
+
toolName: data.toolName,
|
|
1088
|
+
turnCount: data.turnCount,
|
|
1089
|
+
tokens: data.tokens,
|
|
1090
|
+
durationMs: data.durationMs,
|
|
1091
|
+
toolUses: data.toolUses,
|
|
1092
|
+
modelId: data.modelId,
|
|
1093
|
+
status: data.status,
|
|
1094
|
+
error: data.error,
|
|
1095
|
+
result: data.result,
|
|
1096
|
+
});
|
|
1097
|
+
|
|
447
1098
|
const ageMs = lifecycle.createdAt == null ? 0 : now - lifecycle.createdAt;
|
|
448
1099
|
const startedDeltaMs = lifecycle.startedAt == null || lifecycle.createdAt == null ? undefined : lifecycle.startedAt - lifecycle.createdAt;
|
|
449
1100
|
const firstToolDeltaMs = lifecycle.firstToolAt == null || lifecycle.createdAt == null ? undefined : lifecycle.firstToolAt - lifecycle.createdAt;
|
|
@@ -451,8 +1102,8 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
451
1102
|
const pending = orchestrator.pendingSubagentSpawns;
|
|
452
1103
|
const running = orchestrator.spawnedAgentIds.size;
|
|
453
1104
|
const desc = data.description || lifecycle.description || data.type || lifecycle.type || data.id;
|
|
454
|
-
|
|
455
|
-
|
|
1105
|
+
getLogger().debug({
|
|
1106
|
+
s: "subagent",
|
|
456
1107
|
id: data.id,
|
|
457
1108
|
event,
|
|
458
1109
|
description: desc,
|
|
@@ -467,7 +1118,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
467
1118
|
createdToFirstTurnMs: firstTurnDeltaMs ?? null,
|
|
468
1119
|
toolName: data.toolName ?? null,
|
|
469
1120
|
turnCount: data.turnCount ?? null,
|
|
470
|
-
});
|
|
1121
|
+
}, "subagent lifecycle event");
|
|
471
1122
|
}
|
|
472
1123
|
|
|
473
1124
|
function startStaleAgentWatchdog(): void {
|
|
@@ -480,7 +1131,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
480
1131
|
}
|
|
481
1132
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
482
1133
|
const now = Date.now();
|
|
483
|
-
const staleMs = orchestrator.config.
|
|
1134
|
+
const staleMs = orchestrator.config.performance.internals.subagentStale;
|
|
484
1135
|
for (const [id, spawnTime] of orchestrator.agentSpawnTimes) {
|
|
485
1136
|
const record = mgr?.getRecord?.(id);
|
|
486
1137
|
if (record?.status === "running" || record?.status === "queued") continue;
|
|
@@ -492,13 +1143,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
492
1143
|
orchestrator.agentSpawnTimes.delete(id);
|
|
493
1144
|
orchestrator.agentDescriptions.delete(id);
|
|
494
1145
|
orchestrator.agentLifecycle.delete(id);
|
|
495
|
-
|
|
1146
|
+
orchestrator.transitionController.sendCustom(
|
|
496
1147
|
{
|
|
497
1148
|
customType: "pp-agent-stale",
|
|
498
1149
|
content: `Aborted stale agent "${desc}" — no completion after ${Math.round(staleMs / 1000)}s.`,
|
|
499
1150
|
display: true,
|
|
500
1151
|
},
|
|
501
|
-
|
|
1152
|
+
"context",
|
|
502
1153
|
);
|
|
503
1154
|
}
|
|
504
1155
|
}
|
|
@@ -538,6 +1189,15 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
538
1189
|
trackSubagentEvent(data, "first_turn");
|
|
539
1190
|
});
|
|
540
1191
|
|
|
1192
|
+
function markAllAgentsConsumed(): void {
|
|
1193
|
+
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
1194
|
+
if (!mgr?.getRecord) return;
|
|
1195
|
+
for (const id of orchestrator.agentDescriptions.keys()) {
|
|
1196
|
+
const record = mgr.getRecord(id);
|
|
1197
|
+
if (record) record.resultConsumed = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
541
1201
|
function checkPlannerCompletion(): void {
|
|
542
1202
|
if (
|
|
543
1203
|
!orchestrator.active ||
|
|
@@ -545,7 +1205,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
545
1205
|
orchestrator.active.state.step !== "await_planners" ||
|
|
546
1206
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
547
1207
|
orchestrator.pendingSubagentSpawns > 0 ||
|
|
548
|
-
orchestrator.
|
|
1208
|
+
orchestrator.transitionController.isTransitioning()
|
|
549
1209
|
) return;
|
|
550
1210
|
|
|
551
1211
|
const plansDir = join(orchestrator.active.dir, "plans");
|
|
@@ -553,8 +1213,75 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
553
1213
|
readdirSync(plansDir).some((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
|
|
554
1214
|
|
|
555
1215
|
const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
|
|
1216
|
+
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1217
|
+
if (effectiveMode === "autonomous" && failedPlannerVariants.length > 0) {
|
|
1218
|
+
const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
|
|
1219
|
+
if (!alreadyRetried) {
|
|
1220
|
+
const failedSet = new Set(failedPlannerVariants);
|
|
1221
|
+
const presetName = normalizeStoredPlannerPresetName(orchestrator);
|
|
1222
|
+
const planners = resolvePreset(orchestrator.config, "planners", presetName);
|
|
1223
|
+
const scopedPlanners: typeof planners = {};
|
|
1224
|
+
for (const [name, cfg] of Object.entries(planners)) {
|
|
1225
|
+
if (failedSet.has(name)) scopedPlanners[name] = cfg;
|
|
1226
|
+
}
|
|
1227
|
+
const retryCount = Object.keys(scopedPlanners).length;
|
|
1228
|
+
if (retryCount > 0) {
|
|
1229
|
+
orchestrator.active.state.plannerFailureAutoRetried = true;
|
|
1230
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1231
|
+
orchestrator.failedPlannerVariants = [];
|
|
1232
|
+
orchestrator.pendingSubagentSpawns = retryCount;
|
|
1233
|
+
handleSpawnResult(
|
|
1234
|
+
orchestrator,
|
|
1235
|
+
spawnPlanners(
|
|
1236
|
+
pi,
|
|
1237
|
+
orchestrator.cwd,
|
|
1238
|
+
orchestrator.active!.dir,
|
|
1239
|
+
orchestrator.active!.taskId,
|
|
1240
|
+
orchestrator.config,
|
|
1241
|
+
orchestrator.transitionController.phaseSend,
|
|
1242
|
+
scopedPlanners,
|
|
1243
|
+
orchestrator.active?.state.repos ?? [],
|
|
1244
|
+
),
|
|
1245
|
+
{
|
|
1246
|
+
kind: "planner",
|
|
1247
|
+
logScope: "planner",
|
|
1248
|
+
logMessage: "retry spawnPlanners failed",
|
|
1249
|
+
onSettled: checkPlannerCompletion,
|
|
1250
|
+
},
|
|
1251
|
+
);
|
|
1252
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners once: ${failedPlannerVariants.join(", ")}.`);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
orchestrator.failedPlannerVariants = [];
|
|
1258
|
+
orchestrator.active.state.plannerFailureAutoRetried = false;
|
|
1259
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1260
|
+
if (!hasPlanFiles) {
|
|
1261
|
+
// Custom (sendMessage) followUp does NOT start a turn when the agent is
|
|
1262
|
+
// idle (SDK agent-session.js) — emit the payload as display context and
|
|
1263
|
+
// start the synthesizer turn via safeSendUserMessage (sendUserMessage
|
|
1264
|
+
// always triggers a turn).
|
|
1265
|
+
orchestrator.transitionController.sendCustom(
|
|
1266
|
+
{
|
|
1267
|
+
customType: "pp-planners-error",
|
|
1268
|
+
content: "All planner subagents failed. Continue without planner outputs and synthesize the plan yourself.",
|
|
1269
|
+
display: true,
|
|
1270
|
+
},
|
|
1271
|
+
"context",
|
|
1272
|
+
);
|
|
1273
|
+
orchestrator.safeSendUserMessage("[PI-PI] All planners failed. Synthesize the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
|
|
1274
|
+
} else {
|
|
1275
|
+
orchestrator.safeSendUserMessage("[PI-PI] Some planners failed. Continue with available planner outputs.");
|
|
1276
|
+
}
|
|
1277
|
+
orchestrator.active.state.step = "synthesize";
|
|
1278
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
556
1282
|
if (
|
|
557
1283
|
failedPlannerVariants.length > 0 &&
|
|
1284
|
+
effectiveMode !== "autonomous" &&
|
|
558
1285
|
!orchestrator.plannerFailureDialogPending &&
|
|
559
1286
|
orchestrator.lastCtx
|
|
560
1287
|
) {
|
|
@@ -572,28 +1299,35 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
572
1299
|
|
|
573
1300
|
if (choice === "Retry failed planners") {
|
|
574
1301
|
const failedSet = new Set(failedPlannerVariants);
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
1302
|
+
const presetName = normalizeStoredPlannerPresetName(orchestrator);
|
|
1303
|
+
const planners = resolvePreset(orchestrator.config, "planners", presetName);
|
|
1304
|
+
const scopedPlanners: typeof planners = {};
|
|
1305
|
+
for (const [name, cfg] of Object.entries(planners)) {
|
|
1306
|
+
if (failedSet.has(name)) scopedPlanners[name] = cfg;
|
|
578
1307
|
}
|
|
579
|
-
const retryCount = Object.keys(
|
|
1308
|
+
const retryCount = Object.keys(scopedPlanners).length;
|
|
580
1309
|
if (retryCount > 0) {
|
|
581
|
-
const retryConfig = { ...orchestrator.config, planners };
|
|
582
1310
|
orchestrator.failedPlannerVariants = [];
|
|
583
1311
|
orchestrator.pendingSubagentSpawns = retryCount;
|
|
584
|
-
|
|
585
|
-
orchestrator
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
orchestrator.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1312
|
+
handleSpawnResult(
|
|
1313
|
+
orchestrator,
|
|
1314
|
+
spawnPlanners(
|
|
1315
|
+
pi,
|
|
1316
|
+
orchestrator.cwd,
|
|
1317
|
+
orchestrator.active!.dir,
|
|
1318
|
+
orchestrator.active!.taskId,
|
|
1319
|
+
orchestrator.config,
|
|
1320
|
+
orchestrator.transitionController.phaseSend,
|
|
1321
|
+
scopedPlanners,
|
|
1322
|
+
orchestrator.active?.state.repos ?? [],
|
|
1323
|
+
),
|
|
1324
|
+
{
|
|
1325
|
+
kind: "planner",
|
|
1326
|
+
logScope: "planner",
|
|
1327
|
+
logMessage: "retry spawnPlanners failed",
|
|
1328
|
+
onSettled: checkPlannerCompletion,
|
|
1329
|
+
},
|
|
1330
|
+
);
|
|
597
1331
|
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`);
|
|
598
1332
|
return;
|
|
599
1333
|
}
|
|
@@ -616,20 +1350,26 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
616
1350
|
}
|
|
617
1351
|
|
|
618
1352
|
if (!hasPlanFiles) {
|
|
619
|
-
|
|
1353
|
+
// Display payload as context; start the synthesizer turn via safeSendUserMessage
|
|
1354
|
+
// (a custom followUp message does not start an idle turn).
|
|
1355
|
+
orchestrator.transitionController.sendCustom(
|
|
620
1356
|
{
|
|
621
1357
|
customType: "pp-planners-error",
|
|
622
1358
|
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
1359
|
display: true,
|
|
624
1360
|
},
|
|
625
|
-
|
|
1361
|
+
"context",
|
|
626
1362
|
);
|
|
627
1363
|
orchestrator.active.state.step = "synthesize";
|
|
628
1364
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1365
|
+
orchestrator.safeSendUserMessage("[PI-PI] No plan files were produced. Create the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
|
|
629
1366
|
return;
|
|
630
1367
|
}
|
|
631
1368
|
|
|
632
1369
|
orchestrator.failedPlannerVariants = [];
|
|
1370
|
+
orchestrator.active.state.plannerFailureAutoRetried = false;
|
|
1371
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1372
|
+
markAllAgentsConsumed();
|
|
633
1373
|
orchestrator.active.state.step = "synthesize";
|
|
634
1374
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
635
1375
|
orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
|
|
@@ -641,12 +1381,95 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
641
1381
|
orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
|
|
642
1382
|
orchestrator.spawnedAgentIds.size > 0 ||
|
|
643
1383
|
orchestrator.pendingSubagentSpawns > 0 ||
|
|
644
|
-
orchestrator.
|
|
1384
|
+
orchestrator.transitionController.isTransitioning()
|
|
645
1385
|
) return;
|
|
646
1386
|
|
|
647
1387
|
const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
|
|
1388
|
+
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1389
|
+
if (effectiveMode === "autonomous" && failedReviewerVariants.length > 0) {
|
|
1390
|
+
const cycle = orchestrator.active.state.reviewCycle;
|
|
1391
|
+
const phase = orchestrator.active.state.phase;
|
|
1392
|
+
const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
|
|
1393
|
+
const alreadyRetried = orchestrator.active.state.reviewerFailureAutoRetried === true;
|
|
1394
|
+
if (!alreadyRetried) {
|
|
1395
|
+
const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
1396
|
+
const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
1397
|
+
const failedSet = new Set(failedReviewerVariants);
|
|
1398
|
+
const scopedReviewers: typeof sourceReviewers = {};
|
|
1399
|
+
for (const [name, cfg] of Object.entries(sourceReviewers)) {
|
|
1400
|
+
if (failedSet.has(name)) scopedReviewers[name] = cfg;
|
|
1401
|
+
}
|
|
1402
|
+
const retryCount = Object.keys(scopedReviewers).length;
|
|
1403
|
+
if (retryCount > 0) {
|
|
1404
|
+
const spawnFn = phase === "brainstorm"
|
|
1405
|
+
? () => spawnBrainstormReviewers(
|
|
1406
|
+
pi,
|
|
1407
|
+
orchestrator.cwd,
|
|
1408
|
+
orchestrator.active!.dir,
|
|
1409
|
+
orchestrator.active!.taskId,
|
|
1410
|
+
orchestrator.config,
|
|
1411
|
+
cycle.pass,
|
|
1412
|
+
orchestrator.transitionController.phaseSend,
|
|
1413
|
+
scopedReviewers,
|
|
1414
|
+
orchestrator.active?.state.repos ?? [],
|
|
1415
|
+
)
|
|
1416
|
+
: phase === "plan"
|
|
1417
|
+
? () => spawnPlanReviewers(
|
|
1418
|
+
pi,
|
|
1419
|
+
orchestrator.cwd,
|
|
1420
|
+
orchestrator.active!.dir,
|
|
1421
|
+
orchestrator.active!.taskId,
|
|
1422
|
+
orchestrator.config,
|
|
1423
|
+
cycle.pass,
|
|
1424
|
+
orchestrator.transitionController.phaseSend,
|
|
1425
|
+
scopedReviewers,
|
|
1426
|
+
orchestrator.active?.state.repos ?? [],
|
|
1427
|
+
)
|
|
1428
|
+
: () => spawnCodeReviewers(
|
|
1429
|
+
pi,
|
|
1430
|
+
orchestrator.cwd,
|
|
1431
|
+
orchestrator.active!.dir,
|
|
1432
|
+
orchestrator.active!.taskId,
|
|
1433
|
+
orchestrator.config,
|
|
1434
|
+
cycle.pass,
|
|
1435
|
+
phase,
|
|
1436
|
+
orchestrator.transitionController.phaseSend,
|
|
1437
|
+
scopedReviewers,
|
|
1438
|
+
orchestrator.active?.state.repos ?? [],
|
|
1439
|
+
);
|
|
1440
|
+
orchestrator.active.state.reviewerFailureAutoRetried = true;
|
|
1441
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1442
|
+
orchestrator.failedReviewerVariants = [];
|
|
1443
|
+
orchestrator.pendingSubagentSpawns = retryCount;
|
|
1444
|
+
cycle.step = "await_reviewers";
|
|
1445
|
+
orchestrator.active.state.step = "await_reviewers";
|
|
1446
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1447
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
1448
|
+
kind: "reviewer",
|
|
1449
|
+
logScope: "review",
|
|
1450
|
+
logMessage: "retry spawn reviewers failed",
|
|
1451
|
+
logExtra: { phase },
|
|
1452
|
+
onSettled: checkReviewCycleCompletion,
|
|
1453
|
+
});
|
|
1454
|
+
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers once: ${failedReviewerVariants.join(", ")}.`);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
orchestrator.failedReviewerVariants = [];
|
|
1460
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
1461
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1462
|
+
if (outputs.length === 0) {
|
|
1463
|
+
finalizeReviewCycleAutonomous(orchestrator.active);
|
|
1464
|
+
orchestrator.safeSendUserMessage("[PI-PI] All reviewers failed twice. Continue without reviewer outputs and proceed with best judgment.");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
orchestrator.safeSendUserMessage("[PI-PI] Some reviewers failed. Continue with available reviewer outputs.");
|
|
1468
|
+
}
|
|
1469
|
+
|
|
648
1470
|
if (
|
|
649
1471
|
failedReviewerVariants.length > 0 &&
|
|
1472
|
+
effectiveMode !== "autonomous" &&
|
|
650
1473
|
!orchestrator.reviewerFailureDialogPending &&
|
|
651
1474
|
orchestrator.lastCtx
|
|
652
1475
|
) {
|
|
@@ -664,13 +1487,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
664
1487
|
const cycle = orchestrator.active?.state.reviewCycle;
|
|
665
1488
|
if (cycle) {
|
|
666
1489
|
const pass = cycle.pass;
|
|
667
|
-
const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
|
|
668
1490
|
const phase = orchestrator.active!.state.phase;
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
: phase === "plan"
|
|
672
|
-
? reviewConfig.planReviewers
|
|
673
|
-
: reviewConfig.codeReviewers;
|
|
1491
|
+
const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
|
|
1492
|
+
const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
|
|
674
1493
|
const failedSet = new Set(failedReviewerVariants);
|
|
675
1494
|
const scopedReviewers: typeof sourceReviewers = {};
|
|
676
1495
|
for (const [name, cfg] of Object.entries(sourceReviewers)) {
|
|
@@ -678,33 +1497,53 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
678
1497
|
}
|
|
679
1498
|
const retryCount = Object.keys(scopedReviewers).length;
|
|
680
1499
|
if (retryCount > 0) {
|
|
681
|
-
const retryConfig = phase === "brainstorm"
|
|
682
|
-
? { ...reviewConfig, brainstormReviewers: scopedReviewers }
|
|
683
|
-
: phase === "plan"
|
|
684
|
-
? { ...reviewConfig, planReviewers: scopedReviewers }
|
|
685
|
-
: { ...reviewConfig, codeReviewers: scopedReviewers };
|
|
686
1500
|
const spawnFn = phase === "brainstorm"
|
|
687
|
-
? () => spawnBrainstormReviewers(
|
|
1501
|
+
? () => spawnBrainstormReviewers(
|
|
1502
|
+
pi,
|
|
1503
|
+
orchestrator.cwd,
|
|
1504
|
+
orchestrator.active!.dir,
|
|
1505
|
+
orchestrator.active!.taskId,
|
|
1506
|
+
orchestrator.config,
|
|
1507
|
+
pass,
|
|
1508
|
+
orchestrator.transitionController.phaseSend,
|
|
1509
|
+
scopedReviewers,
|
|
1510
|
+
orchestrator.active?.state.repos ?? [],
|
|
1511
|
+
)
|
|
688
1512
|
: phase === "plan"
|
|
689
|
-
? () => spawnPlanReviewers(
|
|
690
|
-
|
|
1513
|
+
? () => spawnPlanReviewers(
|
|
1514
|
+
pi,
|
|
1515
|
+
orchestrator.cwd,
|
|
1516
|
+
orchestrator.active!.dir,
|
|
1517
|
+
orchestrator.active!.taskId,
|
|
1518
|
+
orchestrator.config,
|
|
1519
|
+
pass,
|
|
1520
|
+
orchestrator.transitionController.phaseSend,
|
|
1521
|
+
scopedReviewers,
|
|
1522
|
+
orchestrator.active?.state.repos ?? [],
|
|
1523
|
+
)
|
|
1524
|
+
: () => spawnCodeReviewers(
|
|
1525
|
+
pi,
|
|
1526
|
+
orchestrator.cwd,
|
|
1527
|
+
orchestrator.active!.dir,
|
|
1528
|
+
orchestrator.active!.taskId,
|
|
1529
|
+
orchestrator.config,
|
|
1530
|
+
pass,
|
|
1531
|
+
phase,
|
|
1532
|
+
orchestrator.transitionController.phaseSend,
|
|
1533
|
+
scopedReviewers,
|
|
1534
|
+
orchestrator.active?.state.repos ?? [],
|
|
1535
|
+
);
|
|
691
1536
|
orchestrator.failedReviewerVariants = [];
|
|
692
1537
|
orchestrator.pendingSubagentSpawns = retryCount;
|
|
693
1538
|
cycle.step = "await_reviewers";
|
|
694
1539
|
orchestrator.active!.state.step = "await_reviewers";
|
|
695
1540
|
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();
|
|
1541
|
+
handleSpawnResult(orchestrator, spawnFn(), {
|
|
1542
|
+
kind: "reviewer",
|
|
1543
|
+
logScope: "review",
|
|
1544
|
+
logMessage: "retry spawn reviewers failed",
|
|
1545
|
+
logExtra: { phase },
|
|
1546
|
+
onSettled: checkReviewCycleCompletion,
|
|
708
1547
|
});
|
|
709
1548
|
orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`);
|
|
710
1549
|
return;
|
|
@@ -736,6 +1575,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
736
1575
|
return;
|
|
737
1576
|
}
|
|
738
1577
|
|
|
1578
|
+
if (orchestrator.active.state.reviewerFailureAutoRetried) {
|
|
1579
|
+
orchestrator.active.state.reviewerFailureAutoRetried = false;
|
|
1580
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
1581
|
+
}
|
|
1582
|
+
markAllAgentsConsumed();
|
|
739
1583
|
tryCompleteReviewCycle(orchestrator);
|
|
740
1584
|
}
|
|
741
1585
|
|
|
@@ -764,13 +1608,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
764
1608
|
const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
|
|
765
1609
|
const stats = [duration, tokens].filter(Boolean).join(", ");
|
|
766
1610
|
|
|
767
|
-
|
|
1611
|
+
orchestrator.transitionController.sendCustom(
|
|
768
1612
|
{
|
|
769
1613
|
customType: "pp-subagent-result",
|
|
770
1614
|
content: `${desc} completed${stats ? ` (${stats})` : ""}. Use get_subagent_result to read the output.`,
|
|
771
1615
|
display: false,
|
|
772
1616
|
},
|
|
773
|
-
|
|
1617
|
+
"context",
|
|
774
1618
|
);
|
|
775
1619
|
|
|
776
1620
|
checkPlannerCompletion();
|
|
@@ -797,7 +1641,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
797
1641
|
orchestrator.abortAllSubagents();
|
|
798
1642
|
}
|
|
799
1643
|
|
|
800
|
-
|
|
1644
|
+
orchestrator.transitionController.sendCustom(
|
|
801
1645
|
{
|
|
802
1646
|
customType: "pp-subagent-error",
|
|
803
1647
|
content: isApiError
|
|
@@ -805,7 +1649,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
805
1649
|
: `**${desc}** failed: ${data.error || "unknown error"}. Do NOT retry — continue with available information.`,
|
|
806
1650
|
display: true,
|
|
807
1651
|
},
|
|
808
|
-
|
|
1652
|
+
"context",
|
|
809
1653
|
);
|
|
810
1654
|
|
|
811
1655
|
checkPlannerCompletion();
|
|
@@ -813,6 +1657,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
813
1657
|
});
|
|
814
1658
|
|
|
815
1659
|
pi.on("session_before_switch" as any, async () => {
|
|
1660
|
+
finalizeTracer();
|
|
816
1661
|
if (!orchestrator.active) return;
|
|
817
1662
|
cancelPendingPlannotatorWait(orchestrator);
|
|
818
1663
|
orchestrator.abortAllSubagents();
|
|
@@ -828,14 +1673,47 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
828
1673
|
try {
|
|
829
1674
|
dumpUsageSummary(tracker, sessionId);
|
|
830
1675
|
} catch (err: any) {
|
|
831
|
-
|
|
1676
|
+
getLogger().error({ s: "usage", err: err.message }, "failed to dump usage summary");
|
|
832
1677
|
}
|
|
1678
|
+
flushLogs();
|
|
1679
|
+
finalizeTracer();
|
|
833
1680
|
delete (globalThis as any)[USAGE_TRACKER_KEY];
|
|
834
1681
|
});
|
|
835
1682
|
|
|
836
1683
|
pi.on("session_start", async (_event, ctx) => {
|
|
837
1684
|
orchestrator.lastCtx = ctx;
|
|
838
1685
|
orchestrator.cwd = ctx.cwd;
|
|
1686
|
+
(globalThis as any)[Symbol.for("pi-pi:orchestrator-cwd")] = ctx.cwd;
|
|
1687
|
+
|
|
1688
|
+
const ppDir = join(ctx.cwd, ".pp");
|
|
1689
|
+
const { ensureGitignore } = await import("./orchestrator.js");
|
|
1690
|
+
ensureGitignore(ctx.cwd);
|
|
1691
|
+
|
|
1692
|
+
let earlyLogLevel: "debug" | "info" | "warn" | "error" = "info";
|
|
1693
|
+
try {
|
|
1694
|
+
const { readRawConfig, GLOBAL_CONFIG_PATH } = await import("./config.js");
|
|
1695
|
+
const { isValidLogLevel: checkLevel } = await import("./log.js");
|
|
1696
|
+
const globalRaw = readRawConfig(GLOBAL_CONFIG_PATH);
|
|
1697
|
+
const projectRaw = readRawConfig(join(ppDir, "config.json"));
|
|
1698
|
+
const rawLevel = projectRaw?.general?.logLevel ?? globalRaw?.general?.logLevel;
|
|
1699
|
+
if (checkLevel(rawLevel)) earlyLogLevel = rawLevel;
|
|
1700
|
+
} catch {}
|
|
1701
|
+
|
|
1702
|
+
initSessionLogger(ppDir, earlyLogLevel);
|
|
1703
|
+
const log = getLogger();
|
|
1704
|
+
log.info({ s: "session", cwd: ctx.cwd, logLevel: earlyLogLevel }, "session started");
|
|
1705
|
+
|
|
1706
|
+
const available = (ctx as any).modelRegistry?.getAvailable?.();
|
|
1707
|
+
if (Array.isArray(available)) {
|
|
1708
|
+
const modelIds = available
|
|
1709
|
+
.map((m: any) => {
|
|
1710
|
+
const provider = typeof m?.provider === "string" ? m.provider.trim() : "";
|
|
1711
|
+
const id = typeof m?.id === "string" ? m.id.trim() : "";
|
|
1712
|
+
return provider && id ? `${provider}/${id}` : "";
|
|
1713
|
+
})
|
|
1714
|
+
.filter((id: string) => id.length > 0);
|
|
1715
|
+
updateRegistryFromAvailableModels(modelIds);
|
|
1716
|
+
}
|
|
839
1717
|
|
|
840
1718
|
if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
841
1719
|
const tracker = createUsageTracker();
|
|
@@ -865,7 +1743,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
865
1743
|
`Duplicate tools detected: ${duplicates.join(", ")}. ` +
|
|
866
1744
|
`Remove the conflicting packages: pi remove npm:@tintinweb/pi-subagents npm:@tintinweb/pi-tasks npm:pi-ask-user`;
|
|
867
1745
|
ctx.ui.notify(msg, "error");
|
|
868
|
-
|
|
1746
|
+
getLogger().error({ s: "init", duplicates }, "conflicting extensions detected");
|
|
869
1747
|
return;
|
|
870
1748
|
}
|
|
871
1749
|
|
|
@@ -874,16 +1752,28 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
874
1752
|
setPI(pi);
|
|
875
1753
|
await initFlantOnStartup(pi);
|
|
876
1754
|
} catch (err: any) {
|
|
877
|
-
|
|
1755
|
+
getLogger().error({ s: "flant", err: err.message }, "flant infra init failed");
|
|
878
1756
|
}
|
|
879
1757
|
|
|
880
1758
|
try {
|
|
881
1759
|
orchestrator.config = loadConfig(orchestrator.cwd);
|
|
882
1760
|
} catch (err: any) {
|
|
883
|
-
|
|
1761
|
+
getLogger().error({ s: "config", err: err.message }, "failed to load config on session start");
|
|
884
1762
|
return;
|
|
885
1763
|
}
|
|
886
1764
|
|
|
1765
|
+
setLogLevel(orchestrator.config.general.logLevel);
|
|
1766
|
+
log.info({ s: "config", logLevel: orchestrator.config.general.logLevel }, "config loaded");
|
|
1767
|
+
|
|
1768
|
+
if (orchestrator.config.general.tracing) {
|
|
1769
|
+
const sessionId = ctx.sessionManager?.getSessionId?.() || `session-${Date.now()}`;
|
|
1770
|
+
initTracer(ppDir, sessionId);
|
|
1771
|
+
log.info({ s: "tracing", sessionId }, "session tracing enabled");
|
|
1772
|
+
} else {
|
|
1773
|
+
finalizeTracer();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
registerCommandHandlers(orchestrator);
|
|
887
1777
|
registerCbmTools(pi, orchestrator.cwd);
|
|
888
1778
|
registerExaTools(pi);
|
|
889
1779
|
registerAstSearchTool(pi, orchestrator.cwd);
|
|
@@ -891,7 +1781,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
891
1781
|
setExtensionOnlyMode(pi);
|
|
892
1782
|
orchestrator.registerAgents();
|
|
893
1783
|
|
|
894
|
-
const found = getActiveTask(orchestrator.cwd, orchestrator.config.
|
|
1784
|
+
const found = getActiveTask(orchestrator.cwd, orchestrator.config.performance.internals.taskLockStale);
|
|
895
1785
|
if (found) {
|
|
896
1786
|
ctx.ui.notify(
|
|
897
1787
|
`Paused task: "${taskName(found.dir)}" (${found.type}, phase: ${found.state.phase}). Run /pp and choose Resume to continue.`,
|
|
@@ -913,34 +1803,77 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
913
1803
|
|
|
914
1804
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
915
1805
|
orchestrator.lastCtx = ctx;
|
|
916
|
-
|
|
917
|
-
|
|
1806
|
+
// The controller owns the transition/await abort gate: it decides whether the
|
|
1807
|
+
// agent loop may start (not running during a pending/compacting/resuming
|
|
1808
|
+
// transition, subsuming the old compaction-pending checks, or while awaiting
|
|
1809
|
+
// subagents) AND issues the abort itself.
|
|
1810
|
+
if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
|
|
918
1811
|
return;
|
|
919
1812
|
}
|
|
920
1813
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
921
1814
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1815
|
+
// Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
|
|
1816
|
+
// injected prompts (nudges, "Begin working" handoffs, planner-error prompts)
|
|
1817
|
+
// are all "[PI-PI]"-prefixed and themselves restart the loop via followUp;
|
|
1818
|
+
// resetting on those would make consecutiveNudges oscillate 0->1->0 and the
|
|
1819
|
+
// halt could never fire. A nudge-induced restart must NOT clear the guard.
|
|
1820
|
+
const isControllerInjected = (event.prompt ?? "").startsWith("[PI-PI]");
|
|
1821
|
+
if (!isControllerInjected) {
|
|
1822
|
+
orchestrator.nudgeHalted = false;
|
|
1823
|
+
orchestrator.consecutiveNudges = 0;
|
|
926
1824
|
}
|
|
927
|
-
|
|
928
|
-
orchestrator.nudgeHalted = false;
|
|
929
1825
|
orchestrator.updateStatus(ctx);
|
|
930
1826
|
|
|
931
1827
|
const phasePrompt = orchestrator.getPhasePrompt(ctx);
|
|
932
|
-
const
|
|
1828
|
+
const phase = orchestrator.active?.state.phase;
|
|
1829
|
+
const modelSpec = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
1830
|
+
const modelInfo = getModelInfo(modelSpec);
|
|
1831
|
+
const repos = orchestrator.active?.state.repos ?? [];
|
|
1832
|
+
const contextDirs = getContextDirs(orchestrator.cwd, repos, orchestrator.config.general.loadExtraRepoConfigs);
|
|
1833
|
+
const systemContextFiles = loadAllContextFiles(contextDirs, "main", "system", phase, modelInfo);
|
|
933
1834
|
const systemSnippets = systemContextFiles.map((f) => f.content).join("\n\n");
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
1835
|
+
const effectiveMode: TaskMode = getEffectivePhaseMode(orchestrator.active.state);
|
|
1836
|
+
const now = new Date();
|
|
1837
|
+
const monthYear = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
1838
|
+
|
|
1839
|
+
const projectContext = systemSnippets
|
|
1840
|
+
? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
|
|
1841
|
+
: "";
|
|
1842
|
+
const checklistLine =
|
|
1843
|
+
phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
|
|
1844
|
+
const taskBlock = [
|
|
1845
|
+
"<task>",
|
|
1846
|
+
phasePrompt,
|
|
1847
|
+
checklistLine,
|
|
1848
|
+
`Current month: ${monthYear}. Working directory: ${orchestrator.cwd}.`,
|
|
1849
|
+
"</task>",
|
|
1850
|
+
]
|
|
1851
|
+
.filter(Boolean)
|
|
1852
|
+
.join("\n");
|
|
1853
|
+
|
|
1854
|
+
const fullPrompt = [
|
|
1855
|
+
constraintsBlock(phase as Phase, effectiveMode),
|
|
1856
|
+
PRINCIPLES_BLOCK,
|
|
1857
|
+
TOOLS_BLOCK,
|
|
1858
|
+
projectContext,
|
|
1859
|
+
taskBlock,
|
|
1860
|
+
]
|
|
1861
|
+
.filter(Boolean)
|
|
1862
|
+
.join("\n\n");
|
|
937
1863
|
|
|
938
1864
|
return {
|
|
939
|
-
systemPrompt:
|
|
1865
|
+
systemPrompt: fullPrompt,
|
|
940
1866
|
};
|
|
941
1867
|
});
|
|
942
1868
|
|
|
943
1869
|
pi.on("tool_call", async (event, _ctx) => {
|
|
1870
|
+
getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
|
|
1871
|
+
if (event.toolName === "ask_user" && orchestrator.active) {
|
|
1872
|
+
if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
|
|
1873
|
+
return { block: true, reason: "Autonomous mode — make your best judgment based on available context." };
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
944
1877
|
if (event.toolName === "Agent" && orchestrator.active) {
|
|
945
1878
|
const input = event.input as Record<string, unknown>;
|
|
946
1879
|
const requestedType = ((input.subagent_type as string) || "").toLowerCase();
|
|
@@ -952,16 +1885,16 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
952
1885
|
|
|
953
1886
|
if (isExplore) {
|
|
954
1887
|
input.subagent_type = "explore";
|
|
955
|
-
input.model = orchestrator.config.agents.explore.model;
|
|
956
|
-
input.thinking = orchestrator.config.agents.explore.thinking;
|
|
1888
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
|
|
1889
|
+
input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
|
|
957
1890
|
} else if (isLibrarian) {
|
|
958
1891
|
input.subagent_type = "librarian";
|
|
959
|
-
input.model = orchestrator.config.agents.librarian.model;
|
|
960
|
-
input.thinking = orchestrator.config.agents.librarian.thinking;
|
|
1892
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
|
|
1893
|
+
input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
|
|
961
1894
|
} else {
|
|
962
1895
|
input.subagent_type = "task";
|
|
963
|
-
input.model = orchestrator.config.agents.task.model;
|
|
964
|
-
input.thinking = orchestrator.config.agents.task.thinking;
|
|
1896
|
+
input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
|
|
1897
|
+
input.thinking = orchestrator.config.agents.subagents.simple.task.thinking;
|
|
965
1898
|
}
|
|
966
1899
|
}
|
|
967
1900
|
|
|
@@ -972,25 +1905,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
972
1905
|
const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
|
|
973
1906
|
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
974
1907
|
|
|
975
|
-
if (
|
|
1908
|
+
if (isPathInside(ppStateDir, resolvedPath)) {
|
|
976
1909
|
if (!resolvedPath.endsWith(".md")) {
|
|
977
1910
|
return { block: true, reason: "Cannot write non-.md files in .pp/state/" };
|
|
978
1911
|
}
|
|
979
1912
|
}
|
|
980
1913
|
|
|
981
1914
|
const fileName = basename(resolvedPath);
|
|
982
|
-
if (fileName === "state.json" && (
|
|
1915
|
+
if (fileName === "state.json" && isPathInside(ppDir, resolvedPath)) {
|
|
983
1916
|
return { block: true, reason: "state.json is managed by the extension" };
|
|
984
1917
|
}
|
|
985
1918
|
|
|
986
|
-
if (fileName === "config.json" && (
|
|
1919
|
+
if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
|
|
987
1920
|
return { block: true, reason: "config.json is managed by the user, not the LLM" };
|
|
988
1921
|
}
|
|
989
1922
|
}
|
|
990
1923
|
return;
|
|
991
1924
|
});
|
|
992
1925
|
|
|
993
|
-
pi.on("tool_result", async (event,
|
|
1926
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1927
|
+
// ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
|
|
1928
|
+
// "stop, I want to type"). Only a deliberate user cancel aborts — timeout
|
|
1929
|
+
// and programmatic signal-aborts carry a non-"user" reason and must not.
|
|
1930
|
+
// The benign tool result has already been produced, so abort() yields a
|
|
1931
|
+
// clean stop rather than an error in the agent loop.
|
|
1932
|
+
if (event.toolName === "ask_user") {
|
|
1933
|
+
const cancelReason = (event.details as { cancelReason?: string } | undefined)?.cancelReason;
|
|
1934
|
+
if (cancelReason === "user") {
|
|
1935
|
+
getLogger().debug({ s: "hook", hook: "tool_result", tool: "ask_user" }, "user cancelled ask_user — aborting turn");
|
|
1936
|
+
ctx.abort?.();
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
994
1940
|
if (!orchestrator.active) return;
|
|
995
1941
|
|
|
996
1942
|
if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
|
|
@@ -1025,7 +1971,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1025
1971
|
}
|
|
1026
1972
|
}
|
|
1027
1973
|
const artifactsDir = join(taskDir, "artifacts");
|
|
1028
|
-
if (
|
|
1974
|
+
if (isPathInside(artifactsDir, resolvedWrite) && resolvedWrite.endsWith(".md")) {
|
|
1029
1975
|
const content = readFileSync(resolvedWrite, "utf-8");
|
|
1030
1976
|
const result = validateArtifact(content);
|
|
1031
1977
|
if (!result.ok) {
|
|
@@ -1038,24 +1984,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1038
1984
|
}
|
|
1039
1985
|
}
|
|
1040
1986
|
|
|
1041
|
-
|
|
1987
|
+
const ppDir = resolve(orchestrator.cwd, ".pp");
|
|
1988
|
+
if (isPathInside(ppDir, resolvedWrite)) return;
|
|
1042
1989
|
|
|
1043
1990
|
if (orchestrator.active.state.phase !== "implement") return;
|
|
1044
1991
|
|
|
1045
|
-
orchestrator.active.modifiedFiles.add(
|
|
1992
|
+
orchestrator.active.modifiedFiles.add(resolvedWrite);
|
|
1046
1993
|
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
|
-
}
|
|
1994
|
+
orchestrator.active.state.reviewApprovedClean = false;
|
|
1056
1995
|
try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
|
|
1057
1996
|
|
|
1058
|
-
const
|
|
1997
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
1998
|
+
const repo = resolveRepoForFile(repos, resolvedWrite);
|
|
1999
|
+
getLogger().debug({ s: "afterEdit", file: resolvedWrite, repo: repo?.path ?? null, isRoot: repo?.isRoot ?? null }, "resolving afterEdit commands");
|
|
2000
|
+
const afterEditResults: Array<{ ok: boolean; command: string; output: string }> = [];
|
|
2001
|
+
if (repo) {
|
|
2002
|
+
if (repo.isRoot) {
|
|
2003
|
+
const fileInRepo = relative(orchestrator.cwd, resolvedWrite);
|
|
2004
|
+
afterEditResults.push(
|
|
2005
|
+
...runAfterEdit(
|
|
2006
|
+
fileInRepo,
|
|
2007
|
+
orchestrator.config.commands.afterEdit,
|
|
2008
|
+
orchestrator.config.performance.commands.afterEdit,
|
|
2009
|
+
orchestrator.cwd,
|
|
2010
|
+
),
|
|
2011
|
+
);
|
|
2012
|
+
} else if (orchestrator.config.general.loadExtraRepoConfigs) {
|
|
2013
|
+
const repoCommands = loadRepoAfterEditCommands(repo.path);
|
|
2014
|
+
if (repoCommands && Object.keys(repoCommands).length > 0) {
|
|
2015
|
+
const fileInRepo = relative(repo.path, resolvedWrite);
|
|
2016
|
+
afterEditResults.push(...runAfterEdit(fileInRepo, repoCommands, orchestrator.config.performance.commands.afterEdit, repo.path));
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
1059
2020
|
const failures = afterEditResults.filter((r) => !r.ok);
|
|
1060
2021
|
|
|
1061
2022
|
if (failures.length > 0) {
|
|
@@ -1084,10 +2045,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1084
2045
|
});
|
|
1085
2046
|
|
|
1086
2047
|
pi.on("session_before_compact", async (event, _ctx) => {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
2048
|
+
const transitioning = orchestrator.transitionController.isTransitioning();
|
|
2049
|
+
getLogger().debug({ s: "hook", hook: "session_before_compact", transitioning, state: orchestrator.transitionController.getState() }, "before compact");
|
|
2050
|
+
// Controller-initiated compaction (phase transition or task done/stop/new-task):
|
|
2051
|
+
// supply the transition summary. The controller is the single source of truth,
|
|
2052
|
+
// so this works whether or not `active` is still set (done cleanup nulls it).
|
|
2053
|
+
if (transitioning) {
|
|
2054
|
+
const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
|
|
1091
2055
|
return {
|
|
1092
2056
|
compaction: {
|
|
1093
2057
|
summary,
|
|
@@ -1097,20 +2061,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1097
2061
|
};
|
|
1098
2062
|
}
|
|
1099
2063
|
|
|
2064
|
+
// Natural (user-triggered) compaction: re-inject phase artifacts so context
|
|
2065
|
+
// survives.
|
|
1100
2066
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1101
2067
|
|
|
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
2068
|
const artifacts = getPhaseArtifacts(orchestrator.active.dir, orchestrator.active.state.phase);
|
|
1115
2069
|
if (artifacts.length === 0) return;
|
|
1116
2070
|
|
|
@@ -1118,13 +2072,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1118
2072
|
.map((a) => `=== ${a.name} ===\n${a.content}`)
|
|
1119
2073
|
.join("\n\n");
|
|
1120
2074
|
|
|
1121
|
-
|
|
2075
|
+
orchestrator.transitionController.sendCustom(
|
|
1122
2076
|
{
|
|
1123
2077
|
customType: "pp-artifact-reinject",
|
|
1124
2078
|
content: `[PI-PI ARTIFACTS — re-injected after compaction]\n\n${artifactText}`,
|
|
1125
2079
|
display: false,
|
|
1126
2080
|
},
|
|
1127
|
-
|
|
2081
|
+
"context",
|
|
1128
2082
|
);
|
|
1129
2083
|
|
|
1130
2084
|
return;
|
|
@@ -1136,33 +2090,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1136
2090
|
if (usageTracker && msg?.usage) {
|
|
1137
2091
|
const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
|
|
1138
2092
|
const output = typeof msg.usage.output === "number" ? msg.usage.output : 0;
|
|
2093
|
+
const cacheSupported = typeof msg.usage.cacheRead === "number" || typeof msg.usage.cacheWrite === "number";
|
|
1139
2094
|
const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
|
|
1140
2095
|
const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
|
|
1141
2096
|
const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
|
|
1142
2097
|
const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
|
|
1143
2098
|
const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
|
|
1144
|
-
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
|
|
2099
|
+
usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost, cacheSupported);
|
|
1145
2100
|
(ctx.ui as any)?.requestRender?.();
|
|
1146
2101
|
}
|
|
1147
2102
|
|
|
1148
2103
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1149
|
-
|
|
2104
|
+
// A transition is in flight (controller owns the loop) — skip orthogonal
|
|
2105
|
+
// turn_end work (commit reminder / nudges) until it resumes.
|
|
2106
|
+
if (orchestrator.transitionController.isTransitioning()) return;
|
|
1150
2107
|
orchestrator.updateStatus(ctx);
|
|
1151
2108
|
|
|
1152
2109
|
if (
|
|
1153
2110
|
orchestrator.active.state.phase === "implement" &&
|
|
1154
|
-
orchestrator.config.autoCommit &&
|
|
2111
|
+
orchestrator.config.general.autoCommit &&
|
|
1155
2112
|
orchestrator.active.modifiedFiles.size > 0 &&
|
|
1156
2113
|
!orchestrator.commitReminderSent
|
|
1157
2114
|
) {
|
|
1158
2115
|
orchestrator.commitReminderSent = true;
|
|
1159
|
-
|
|
2116
|
+
orchestrator.transitionController.sendCustom(
|
|
1160
2117
|
{
|
|
1161
2118
|
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.`,
|
|
2119
|
+
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
2120
|
display: false,
|
|
1164
2121
|
},
|
|
1165
|
-
|
|
2122
|
+
"context",
|
|
1166
2123
|
);
|
|
1167
2124
|
}
|
|
1168
2125
|
|
|
@@ -1171,20 +2128,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1171
2128
|
if (msg?.stopReason === "aborted") return;
|
|
1172
2129
|
if (msg?.stopReason === "error") {
|
|
1173
2130
|
const errorMsg = msg.errorMessage || "unknown error";
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
2131
|
+
const contentSummary = (msg.content || []).map((c: any) => {
|
|
2132
|
+
if (c.type === "toolCall") return `toolCall:${c.name}`;
|
|
2133
|
+
if (c.type === "text" && c.text) return `text:${c.text.slice(0, 100)}`;
|
|
2134
|
+
if (c.type === "thinking") return `thinking:${c.thinking?.slice(0, 100) || "(redacted)"}`;
|
|
2135
|
+
return c.type;
|
|
2136
|
+
});
|
|
2137
|
+
getLogger().error({
|
|
2138
|
+
s: "turn",
|
|
2139
|
+
err: errorMsg,
|
|
2140
|
+
model: msg.model,
|
|
2141
|
+
provider: msg.provider,
|
|
2142
|
+
api: msg.api,
|
|
2143
|
+
input: msg.usage?.input,
|
|
2144
|
+
output: msg.usage?.output,
|
|
2145
|
+
cacheRead: msg.usage?.cacheRead,
|
|
2146
|
+
contentBlocks: msg.content?.length ?? 0,
|
|
2147
|
+
contentSummary,
|
|
2148
|
+
}, "turn ended with error");
|
|
1183
2149
|
orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
|
|
1184
|
-
const
|
|
1185
|
-
if (orchestrator.errorRetryCount <=
|
|
1186
|
-
const delay =
|
|
1187
|
-
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${
|
|
2150
|
+
const maxRetries = 5;
|
|
2151
|
+
if (orchestrator.errorRetryCount <= maxRetries) {
|
|
2152
|
+
const delay = 2000 * Math.pow(2, orchestrator.errorRetryCount - 1);
|
|
2153
|
+
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
|
|
1188
2154
|
const taskToken = orchestrator.activeTaskToken;
|
|
1189
2155
|
if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
|
|
1190
2156
|
orchestrator.pendingRetryTimer = setTimeout(() => {
|
|
@@ -1193,7 +2159,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1193
2159
|
orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
|
|
1194
2160
|
}, delay);
|
|
1195
2161
|
} else {
|
|
1196
|
-
ctx.ui.notify(`API error persisted after
|
|
2162
|
+
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
|
|
1197
2163
|
orchestrator.errorRetryCount = 0;
|
|
1198
2164
|
}
|
|
1199
2165
|
return;
|
|
@@ -1204,76 +2170,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1204
2170
|
return;
|
|
1205
2171
|
}
|
|
1206
2172
|
|
|
2173
|
+
// While awaiting subagents, completion is driven entirely by the event-driven
|
|
2174
|
+
// owners (checkPlannerCompletion / checkReviewCycleCompletion / tryCompleteReviewCycle,
|
|
2175
|
+
// wired to subagents:completed/failed and the blocking spawn promise's onSettled).
|
|
2176
|
+
// The old 5s setInterval poller was a redundant fallback and has been removed;
|
|
2177
|
+
// turn_end simply returns here (no nudge, no poll) while in an await_* step.
|
|
1207
2178
|
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
2179
|
return;
|
|
1269
2180
|
}
|
|
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
2181
|
|
|
1278
2182
|
const contentParts = Array.isArray(msg?.content) ? msg.content : [];
|
|
1279
2183
|
const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
|
|
@@ -1281,78 +2185,51 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1281
2185
|
const hasToolResults = event.toolResults && event.toolResults.length > 0;
|
|
1282
2186
|
const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
|
|
1283
2187
|
|
|
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;
|
|
2188
|
+
const isAutonomous = getEffectivePhaseMode(orchestrator.active.state) === "autonomous";
|
|
1304
2189
|
|
|
1305
|
-
|
|
2190
|
+
// Nudges fire ONLY for plan/implement. brainstorm/debug/review (and quick) are
|
|
2191
|
+
// interactive by nature — stopping there is normal, not a stall. They are also
|
|
2192
|
+
// suppressed whenever the controller is not running (a transition handoff or
|
|
2193
|
+
// await is in progress — that pause is plumbing, not a genuine model stop).
|
|
2194
|
+
const nudgesEnabled = (phase === "plan" || phase === "implement") && orchestrator.transitionController.isRunning();
|
|
1306
2195
|
|
|
1307
|
-
|
|
1308
|
-
|
|
2196
|
+
// A genuine model stop is a turn that produced no forward progress: either a
|
|
2197
|
+
// text-only reply (no tool call, or one that trails into text) or a fully
|
|
2198
|
+
// empty turn. A turn that ended on a tool call IS progress and resets the guard.
|
|
2199
|
+
const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
|
|
2200
|
+
const endsWithText = lastPart?.type === "text" && !!lastPart?.text?.trim();
|
|
2201
|
+
const isGenuineStop = turnWasEmpty || (hasText && (!hasToolCalls || endsWithText));
|
|
1309
2202
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
if (orchestrator.nudgeTimestamps.length <= 3) {
|
|
1315
|
-
sendNudge();
|
|
2203
|
+
if (!isGenuineStop) {
|
|
2204
|
+
// Forward progress — clear the consecutive-nudge guard.
|
|
2205
|
+
orchestrator.consecutiveNudges = 0;
|
|
1316
2206
|
return;
|
|
1317
2207
|
}
|
|
1318
2208
|
|
|
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
|
-
}
|
|
2209
|
+
if (!nudgesEnabled || orchestrator.nudgeHalted) return;
|
|
1337
2210
|
|
|
1338
|
-
|
|
2211
|
+
// Single consecutive-nudge guard: nudge up to MAX_CONSECUTIVE_NUDGES times in
|
|
2212
|
+
// a row, then halt with one notification until the user re-engages (a fresh
|
|
2213
|
+
// before_agent_start resets the counter).
|
|
2214
|
+
const MAX_CONSECUTIVE_NUDGES = 6;
|
|
2215
|
+
if (orchestrator.consecutiveNudges >= MAX_CONSECUTIVE_NUDGES) {
|
|
2216
|
+
orchestrator.nudgeHalted = true;
|
|
2217
|
+
orchestrator.transitionController.sendCustom(
|
|
1339
2218
|
{
|
|
1340
|
-
customType: "pp-continuation-
|
|
1341
|
-
content: "Agent interrupted
|
|
2219
|
+
customType: "pp-continuation-halted",
|
|
2220
|
+
content: "Agent has been repeatedly interrupted without making progress. Auto-continuation paused. Send any message to resume.",
|
|
1342
2221
|
display: true,
|
|
1343
2222
|
},
|
|
1344
|
-
|
|
2223
|
+
"context",
|
|
1345
2224
|
);
|
|
1346
|
-
orchestrator.nudgeTimestamps = [];
|
|
1347
|
-
const cooldownToken = orchestrator.activeTaskToken;
|
|
1348
|
-
setTimeout(() => {
|
|
1349
|
-
if (orchestrator.activeTaskToken !== cooldownToken || !orchestrator.active) return;
|
|
1350
|
-
sendNudge();
|
|
1351
|
-
}, 60000);
|
|
1352
2225
|
return;
|
|
1353
2226
|
}
|
|
1354
2227
|
|
|
1355
|
-
|
|
2228
|
+
orchestrator.consecutiveNudges++;
|
|
2229
|
+
const nudge = isAutonomous
|
|
2230
|
+
? `[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.`
|
|
2231
|
+
: `[PI-PI] Continue the ${phase} phase where you left off.`;
|
|
2232
|
+
orchestrator.safeSendUserMessage(nudge);
|
|
1356
2233
|
});
|
|
1357
2234
|
|
|
1358
2235
|
|